├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ └── publish.yml ├── README.md ├── __init__.py ├── flow ├── api_handlers.py ├── constants.py ├── downloader.py ├── flow_manager.py ├── flow_node.py ├── route_manager.py └── server_setup.py ├── pyproject.toml └── web ├── core ├── content.html ├── css │ ├── main.css │ └── themes.css ├── js │ └── common │ │ ├── components │ │ ├── BlobMessageProcessor.js │ │ ├── CanvasComponent.js │ │ ├── DataComponent.js │ │ ├── DimSelector.js │ │ ├── Dropdown.js │ │ ├── DropdownStepper.js │ │ ├── IMessageProcessor.js │ │ ├── ImageLoader.js │ │ ├── InputComponent.js │ │ ├── JSONMessageProcessor.js │ │ ├── LoraWorkflowManager.js │ │ ├── MultiComponent.js │ │ ├── MultiStepper.js │ │ ├── Seeder.js │ │ ├── Stepper.js │ │ ├── ToggleComponent.js │ │ ├── WorkflowNodeAdder.js │ │ ├── canvas │ │ │ ├── CanvasControlsPlugin.js │ │ │ ├── CanvasLoader.js │ │ │ ├── CanvasManager.js │ │ │ ├── CanvasPlugin.js │ │ │ ├── CanvasScaleForSavePlugin.js │ │ │ ├── CustomBrushPlugin.js │ │ │ ├── EventEmitter.js │ │ │ ├── ImageAdderPlugin.js │ │ │ ├── ImageCompareSliderPlugin.js │ │ │ ├── ImageLoaderPlugin.js │ │ │ ├── MaskBrushPlugin.js │ │ │ ├── MaskExportUtilities.js │ │ │ ├── UndoRedoPlugin.js │ │ │ └── fabric.5.2.4.min.js │ │ ├── dropdownHandler.js │ │ ├── footer.js │ │ ├── header.js │ │ ├── headerLinker.js │ │ ├── imageLoaderComp.js │ │ ├── imagedisplay.js │ │ ├── messageHandler.js │ │ ├── mimeTypeDetector.js │ │ ├── missingPackagesDialog.js │ │ ├── previewManager.js │ │ ├── progressbar.js │ │ ├── utils.js │ │ ├── webSocketHandler.js │ │ ├── workflowLoader.js │ │ └── workflowManager.js │ │ └── scripts │ │ ├── ThemeManager.js │ │ ├── componentTypes.js │ │ ├── corePath.js │ │ ├── corePathLinker.js │ │ ├── favicon.js │ │ ├── fetchWorkflow.js │ │ ├── fetchflowConfig.js │ │ ├── init.js │ │ ├── injectStylesheet.js │ │ ├── interactiveUI.js │ │ ├── nodesscanner.js │ │ ├── preferences.js │ │ ├── state.js │ │ ├── stateManager.js │ │ ├── stateManagerMain.js │ │ ├── templates.js │ │ ├── ui_utils.js │ │ └── workflowLoader.js ├── loadScripts.js ├── loadScriptsLinker.js ├── main.js ├── media │ ├── git │ │ ├── cover_flow.jpg │ │ ├── flow_1.jpg │ │ ├── flow_2.jpg │ │ ├── flow_3.jpg │ │ ├── flow_4.jpg │ │ ├── flow_5.jpg │ │ ├── flow_yt.jpg │ │ └── patreon.svg │ └── ui │ │ ├── area_in_zoom_icon.png │ │ ├── area_out_zoom_icon.png │ │ ├── area_zoom_icon.png │ │ ├── dice.png │ │ ├── double-face-mask.png │ │ ├── drop_image_main.png │ │ ├── drop_image_main2.png │ │ ├── drop_image_rect.png │ │ ├── drop_image_rect_no_border.png │ │ ├── drop_image_rect_no_border_trans.png │ │ ├── flow_logo.png │ │ ├── g_flow_logo.png │ │ ├── i2i.png │ │ ├── minus_icon_n.png │ │ ├── paintree.png │ │ ├── pen_icon.png │ │ ├── plus_icon_n.png │ │ ├── r_flow_logo.png │ │ ├── top-view_30trans.png │ │ └── update_logo.png └── templates │ └── index.html ├── flow ├── conf.json ├── index.html └── js │ └── main.js └── linker ├── app.js ├── componentHandler.js ├── configHandler.js ├── css └── styles.css ├── defFlowConfig.json ├── defwf.json ├── fileHandler.js ├── flow.html ├── flowConfig.json ├── index.html ├── linker.html ├── media └── thumbnail.jpg ├── multiComponentHandler.js ├── nodeHandler.js ├── wf.json └── workflowHandler.js /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Something is not right with the Flow. 3 | title: "[Bug]: " 4 | labels: ["bug-report"] 5 | 6 | body: 7 | - type: checkboxes 8 | attributes: 9 | label: Where is the issue? 10 | description: Is the issue in the "Custom Node 'Flow'" UI, or does it concern a specific flow? 11 | options: 12 | - label: The issue is with the "Custom Node 'Flow'" 13 | - label: The issue is with a specific flow. Please refer to [Flows repository](https://github.com/diStyApps/flows_lib) if the problem lies with a specific flow. 14 | - label: I’m not sure where the issue is 15 | 16 | - type: markdown 17 | attributes: 18 | value: | 19 | *Please provide detailed information to help us resolve the issue. Screenshots and logs are highly appreciated.* 20 | 21 | - type: textarea 22 | id: bug-description 23 | attributes: 24 | label: Bug Description 25 | description: A clear and concise description of the issue you're encountering. 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: steps 31 | attributes: 32 | label: Step-by-step instructions to reproduce the issue 33 | description: Be as specific as possible. 34 | validations: 35 | required: true 36 | 37 | - type: textarea 38 | id: expected-behavior 39 | attributes: 40 | label: Expected Behavior 41 | description: Describe what you expected to happen. 42 | validations: 43 | required: true 44 | 45 | - type: textarea 46 | id: current-behavior 47 | attributes: 48 | label: Current Behavior 49 | description: Describe what is currently happening instead. 50 | validations: 51 | required: true 52 | 53 | - type: input 54 | id: version 55 | attributes: 56 | label: Flow Version or Commit 57 | description: "Specify the version or commit of Flow you're using. Avoid 'Latest Version'—instead, provide the exact version (e.g., v1.0.1)." 58 | validations: 59 | required: true 60 | 61 | - type: textarea 62 | id: media-logs 63 | attributes: 64 | label: Media and Logs 65 | description: Please provide any relevant media or logs to help diagnose the issue. If the logs are long, consider sharing via Pastebin or a similar service. 66 | render: Shell 67 | validations: 68 | required: true 69 | 70 | - type: textarea 71 | id: additional-info 72 | attributes: 73 | label: Additional Information 74 | description: Include any other relevant information or context that may help. 75 | -------------------------------------------------------------------------------- /.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 | 6 | body: 7 | - type: checkboxes 8 | attributes: 9 | label: Is there an existing issue for this? 10 | description: Please search to see if an issue already exists for the feature you want, and that it's not implemented in a recent build/commit. 11 | options: 12 | - label: I have searched the existing issues and checked the recent builds/commits 13 | required: true 14 | - type: markdown 15 | attributes: 16 | value: | 17 | *Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible* 18 | - type: textarea 19 | id: feature 20 | attributes: 21 | label: Summary 22 | description: Briefly describe the new feature you would like to request. 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: workflow 27 | attributes: 28 | label: Description 29 | description: Provide a detailed description of the feature you are suggesting. Explain how it would work, what problem it would solve, and any potential benefits. 30 | value: 31 | validations: 32 | required: true 33 | - type: textarea 34 | id: misc 35 | attributes: 36 | label: Additional information 37 | description: Add any other context or screenshots about the feature request here. 38 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths: 9 | - "pyproject.toml" 10 | 11 | jobs: 12 | publish-node: 13 | name: Publish Custom Node to registry 14 | runs-on: ubuntu-latest 15 | # if this is a forked repository. Skipping the workflow. 16 | if: github.event.repository.fork == false 17 | steps: 18 | - name: Check out code 19 | uses: actions/checkout@v4 20 | - name: Publish Custom Node 21 | uses: Comfy-Org/publish-node-action@main 22 | with: 23 | ## Add your own personal access token to your Github Repository secrets and reference it here. 24 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 17 |
18 |
19 | 20 | [![Patreon](https://img.shields.io/badge/Patreon-Support-orange?logo=patreon&logoColor=white&style=for-the-badge)](https://www.patreon.com/distyx) 21 | [![Discord](https://img.shields.io/badge/Discord-Join-blue?logo=discord&logoColor=white&style=for-the-badge)](https://discord.com/invite/M3PWExxVbP) 22 | [![YouTube](https://img.shields.io/badge/YouTube-Subscribe-red?logo=youtube&logoColor=white&style=for-the-badge)](https://www.youtube.com/@Flowcomfy/videos) 23 | 24 | 25 | 26 | 27 | 28 |

Flow - Streamlined Way to ComfyUI

29 | 30 | 31 |

Let your creativity flow naturally

32 |

Don't forget to leave a star.

33 | 34 | 35 |

36 | Report Bug 37 |

38 |
39 | 40 | 41 | 42 | 43 |
44 | Table of Contents 45 |
    46 |
  1. 47 | About The Project 48 |
  2. 49 |
  3. Installation
  4. 50 |
  5. Roadmap
  6. 51 |
  7. Contact
  8. 52 |
  9. My Other Projects
  10. 53 | 54 | 55 |
56 |
57 | 58 | 59 | ## About The Project 60 | 61 | Flow is a custom node designed to provide user-friendly interface for ComfyUI by acting as an alternative user interface for running workflows. It is not a replacement for workflow creation. 62 | 63 | Flow is currently in the early stages of development, so expect bugs and ongoing feature enhancements. With your support and feedback, Flow will settle into a steady stream. 64 | 65 | ### Watch on Youtube 66 | 67 | [![Flow](web/core/media/git/flow_yt.jpg)](https://www.youtube.com/watch?v=g8zMs2B5tic "Flow") 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |

(back to top)

81 | 82 | #### Requirements 83 | - [ComfyUI](https://github.com/comfyanonymous/ComfyUI) 84 | - [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) 85 | 86 | 87 | ## Installation 88 | 89 | 90 | In [ComfyUI](https://github.com/comfyanonymous/ComfyUI) root folder, navigate to the `custom_nodes` folder and run the following command: 91 | 92 | Open the terminal, or in the address bar type CMD to open a command prompt, then run the following command: 93 | 94 | ```bash 95 | git clone https://github.com/diStyApps/ComfyUI-disty-Flow 96 | ``` 97 | 98 | To run Flow, navigate to this address in your web browser: 99 | 100 | ```bash 101 | http://127.0.0.1:8188/flow 102 | ``` 103 | 104 |

(back to top)

105 | 106 | 107 | ## Roadmap 108 | 109 | ### Flow Customization 110 | - [x] Flow Linker 111 | - - [ ] Improved Flow Linker 112 | - Expanded Flow Components 113 | - Enhanced Customization Options 114 | 115 | ### Feature Support 116 | - [x] Canvas / Masking / Inpainting Functionality 117 | - - [x] Improved Canvas / Masking / Inpainting Functionality 118 | - - [ ] Outpainting Functionality 119 | - - Improving Inpainting Functionality 120 | 121 | - [ ] Enhanced Media Handling 122 | - [x] Live Preview 123 | - [ ] Prompt Tracking 124 | 125 | ### UI/UX Enhancements 126 | - [ ] Status Bar Implementation 127 | - [ ] Optimized Menu for Flow Organization 128 | - General User Interface Improvements 129 | 130 | ### Media and Model Management 131 | - [ ] Media Gallery Integration 132 | - [x] Model Gallery Integration 133 | - - Improved Model Gallery 134 | ### Pre-built Flows 135 | - Exclusive Flows for various tasks 136 | - Task-specific Flows 137 | - Continuous updates to support evolving needs 138 | 139 | ### Codebase Improvements 140 | - [ ] Code Optimization and Refactoring 141 | - [ ] Better Error Handling 142 | - [ ] Improved Event Handling 143 | 144 |

(back to top)

145 | 146 | 147 | ## Contact 148 | 149 | distty@gmail.com 150 | 151 | 152 |

(back to top)

153 | 154 | 155 | ## My Other Projects 156 | 157 | [SEAIT](https://github.com/diStyApps/seait) 158 | 159 | [ComfyUI_FrameMaker](https://github.com/diStyApps/ComfyUI_FrameMaker) 160 | 161 | [VisionCrafter](https://github.com/diStyApps/VisionCrafter) 162 | 163 | [FaceSwapSuite](https://github.com/diStyApps/FaceSwapSuite) 164 | 165 | [VisualClipPicker](https://github.com/diStyApps/VisualClipPicker) 166 | 167 | [Stable-Diffusion-Pickle-Scanner-GUI](https://github.com/diStyApps/Stable-Diffusion-Pickle-Scanner-GUI) 168 | 169 | [Safe-and-Stable-Ckpt2Safetensors-Conversion-Tool-GUI](https://github.com/diStyApps/Safe-and-Stable-Ckpt2Safetensors-Conversion-Tool-GUI) 170 | 171 | 172 |

(back to top)

173 | 174 | 175 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .flow.flow_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS 2 | from .flow.constants import WEB_DIRECTORY 3 | from .flow.server_setup import setup_server 4 | setup_server() 5 | __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] 6 | -------------------------------------------------------------------------------- /flow/constants.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mimetypes 3 | from pathlib import Path 4 | import re 5 | APP_NAME = "Flow" 6 | APP_VERSION = "0.5.2" 7 | FLOWMSG = f"\033[38;5;129mFlow - {APP_VERSION}\033[0m" 8 | APP_CONFIGS = [] 9 | 10 | CURRENT_DIR = Path(__file__).parent 11 | ROOT_DIR = CURRENT_DIR.parent 12 | WEBROOT = ROOT_DIR / "web" 13 | CORE_PATH = WEBROOT / "core" 14 | FLOW_PATH = WEBROOT / "flow" 15 | FLOWS_PATH = WEBROOT / "flows" 16 | LINKER_PATH = WEBROOT / "linker" 17 | CUSTOM_THEMES_DIR = WEBROOT / 'custom-themes' 18 | WEB_DIRECTORY = "web/core/js/common/scripts" 19 | 20 | CUSTOM_NODES_DIR = ROOT_DIR.parent 21 | EXTENSION_NODE_MAP_PATH = ROOT_DIR.parent / "ComfyUI-Manager" / "extension-node-map.json" 22 | 23 | FLOWS_DOWNLOAD_PATH = 'https://github.com/diStyApps/flows_lib' 24 | 25 | SAFE_FOLDER_NAME_REGEX = re.compile(r'^[\w\-]+$') 26 | ALLOWED_EXTENSIONS = {'css'} 27 | mimetypes.add_type('application/javascript', '.js') 28 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 29 | logger = logging.getLogger(__name__) 30 | FLOWS_CONFIG_FILE = 'flowConfig.json' 31 | FLOWS_TO_REMOVE = [ 32 | "afl_CogVideoX-Fun-i2v-es", 33 | "afl_CogVideoX-Fun-i2v", 34 | "afl_MimicMotioni2v", 35 | "afl_abase", 36 | "afl_abasei2i", 37 | "afl_abasesd35t3v", 38 | "afl_abasevea", 39 | "afl_abaseveai2i", 40 | "afl_base-fluxd_at2i", 41 | "afl_base-fluxdggufi2i", 42 | "afl_base-fluxdgguft2i", 43 | "afl_base-fluxdi2i", 44 | "afl_base-fluxs_ai2t", 45 | "afl_base-fluxsi2i", 46 | "afl_baseAD", 47 | "afl_baseAdLcm", 48 | "afl_cogvidx_at2v", 49 | "afl_cogvidxi2v", 50 | "afl_cogvidxinteri2v", 51 | "afl_flowup", 52 | "afl_flux_dev", 53 | "afl_flux_dev_lora", 54 | "afl_genfill", 55 | "afl_ipivsMorph", 56 | "afl_mochi2v", 57 | "afl_pulid_flux", 58 | "afl_pulid_flux_GGUF", 59 | "afl_reactor" 60 | "5otvy-cogvideox-orbit-left-lora", 61 | "umbi9-hunyuan-text-to-video", 62 | ] 63 | 64 | NODE_CLASS_MAPPINGS = {} 65 | NODE_DISPLAY_NAME_MAPPINGS = {} 66 | -------------------------------------------------------------------------------- /flow/downloader.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import tempfile 3 | import shutil 4 | from pathlib import Path 5 | from .constants import FLOWS_DOWNLOAD_PATH, FLOWS_PATH, FLOWS_TO_REMOVE, FLOWMSG, logger 6 | 7 | def download_update_flows() -> None: 8 | try: 9 | for flow in FLOWS_TO_REMOVE: 10 | flow_path = FLOWS_PATH / flow 11 | if flow_path.exists() and flow_path.is_dir(): 12 | # logger.info(f"{FLOWMSG}: Removing existing flow directory '{flow}'") 13 | shutil.rmtree(flow_path) 14 | # logger.debug(f"{FLOWMSG}: Successfully removed '{flow}'") 15 | 16 | with tempfile.TemporaryDirectory() as tmpdirname: 17 | temp_repo_path = Path(tmpdirname) / "Flows" 18 | logger.info(f"{FLOWMSG}: Downloading and Upading Flows") 19 | 20 | result = subprocess.run( 21 | ['git', 'clone', FLOWS_DOWNLOAD_PATH, str(temp_repo_path)], 22 | capture_output=True, 23 | text=True 24 | ) 25 | if result.returncode != 0: 26 | logger.error(f"{FLOWMSG}: Failed to clone flows repository:\n{result.stderr}") 27 | return 28 | else: 29 | # logger.debug(f"{FLOWMSG}: Successfully cloned flows repository") 30 | pass 31 | 32 | if not FLOWS_PATH.exists(): 33 | FLOWS_PATH.mkdir(parents=True) 34 | # logger.debug(f"{FLOWMSG}: Created flows directory at '{FLOWS_PATH}'") 35 | 36 | for item in temp_repo_path.iterdir(): 37 | if item.name in ['.git', '.github']: 38 | # logger.debug(f"{FLOWMSG}: Skipping directory '{item.name}'") 39 | continue 40 | dest_item = FLOWS_PATH / item.name 41 | if item.is_dir(): 42 | if dest_item.exists(): 43 | # logger.info(f"{FLOWMSG}: Updating existing directory '{item.name}'") 44 | _copy_directory(item, dest_item) 45 | else: 46 | shutil.copytree(item, dest_item) 47 | # logger.info(f"{FLOWMSG}: Copied new directory '{item.name}'") 48 | else: 49 | shutil.copy2(item, dest_item) 50 | # logger.info(f"{FLOWMSG}: Copied file '{item.name}'") 51 | 52 | logger.info(f"{FLOWMSG}: Flows have been updated successfully.") 53 | except Exception as e: 54 | logger.error(f"{FLOWMSG}: An error occurred while downloading or updating flows: {e}") 55 | 56 | def _copy_directory(src: Path, dest: Path) -> None: 57 | for item in src.iterdir(): 58 | if item.name in ['.git', '.github']: 59 | continue 60 | dest_item = dest / item.name 61 | if item.is_dir(): 62 | if not dest_item.exists(): 63 | dest_item.mkdir() 64 | _copy_directory(item, dest_item) 65 | else: 66 | shutil.copy2(item, dest_item) 67 | -------------------------------------------------------------------------------- /flow/flow_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from aiohttp import web 4 | from typing import Dict, Any 5 | from .constants import ( 6 | FLOWS_PATH, CORE_PATH, LINKER_PATH, FLOW_PATH, APP_CONFIGS, FLOWMSG,FLOWS_CONFIG_FILE, logger 7 | ) 8 | from .route_manager import RouteManager 9 | from .api_handlers import ( 10 | list_themes_handler, get_theme_css_handler, flow_version_handler, 11 | apps_handler, extension_node_map_handler, 12 | install_package_handler, update_package_handler, uninstall_package_handler, 13 | installed_custom_nodes_handler, preview_flow_handler, 14 | reset_preview_handler, create_flow_handler, update_flow_handler, delete_flow_handler, 15 | set_model_preview_handler, 16 | clear_model_preview_handler, 17 | list_model_previews_handler, 18 | get_model_preview_handler 19 | ) 20 | 21 | class FlowManager: 22 | @staticmethod 23 | def setup_app_routes(app: web.Application) -> None: 24 | try: 25 | FlowManager._setup_flows_routes(app) 26 | 27 | FlowManager._setup_core_routes(app) 28 | 29 | FlowManager._setup_api_routes(app) 30 | 31 | FlowManager._setup_additional_routes(app) 32 | 33 | except Exception as e: 34 | logger.error(f"{FLOWMSG}: Failed to set up routes: {e}") 35 | 36 | @staticmethod 37 | def _setup_flows_routes(app: web.Application) -> None: 38 | for flow_dir in filter(lambda d: d.is_dir(), FLOWS_PATH.iterdir()): 39 | conf_file = flow_dir / FLOWS_CONFIG_FILE 40 | if not conf_file.is_file(): 41 | # logger.warning(f"{FLOWMSG}: Config file not found in {flow_dir}") 42 | continue 43 | 44 | conf = FlowManager._load_config(conf_file) 45 | flow_url = conf.get('url') 46 | if not flow_url: 47 | logger.warning(f"{FLOWMSG}: Missing 'url' in config for {flow_dir}") 48 | continue 49 | 50 | app.add_routes(RouteManager.create_routes(f"flow/{flow_url}", flow_dir)) 51 | APP_CONFIGS.append(conf) 52 | 53 | @staticmethod 54 | def _setup_core_routes(app: web.Application) -> None: 55 | if CORE_PATH.is_dir(): 56 | app.router.add_get('/core/css/themes/list', list_themes_handler) 57 | app.router.add_get('/core/css/themes/{filename}', get_theme_css_handler) 58 | app.router.add_static('/core/', path=CORE_PATH, name='core') 59 | 60 | @staticmethod 61 | def _setup_api_routes(app: web.Application) -> None: 62 | api_routes = [ 63 | (f'/flow/api/apps', 'GET', apps_handler), 64 | (f'/flow/api/extension-node-map', 'GET', extension_node_map_handler), 65 | (f'/flow/api/install-package', 'POST', install_package_handler), 66 | (f'/flow/api/update-package', 'POST', update_package_handler), 67 | (f'/flow/api/uninstall-package', 'POST', uninstall_package_handler), 68 | (f'/flow/api/flow-version', 'GET', flow_version_handler), 69 | (f'/flow/api/installed-custom-nodes', 'GET', installed_custom_nodes_handler), 70 | (f'/flow/api/preview-flow', 'POST', preview_flow_handler), 71 | (f'/flow/api/reset-preview', 'POST', reset_preview_handler), 72 | (f'/flow/api/create-flow', 'POST', create_flow_handler), 73 | (f'/flow/api/update-flow', 'POST', update_flow_handler), 74 | (f'/flow/api/delete-flow', 'DELETE', delete_flow_handler), 75 | (f'/flow/api/model-preview', 'POST', set_model_preview_handler), 76 | (f'/flow/api/model-preview', 'DELETE', clear_model_preview_handler), 77 | (f'/flow/api/model-previews', 'POST', list_model_previews_handler), 78 | (f'/flow/api/model-preview', 'GET', get_model_preview_handler), 79 | ] 80 | 81 | for path, method, handler in api_routes: 82 | if method == 'GET': 83 | app.router.add_get(path, handler) 84 | elif method == 'POST': 85 | app.router.add_post(path, handler) 86 | elif method == 'DELETE': 87 | app.router.add_delete(path, handler) 88 | 89 | @staticmethod 90 | def _setup_additional_routes(app: web.Application) -> None: 91 | if LINKER_PATH.is_dir(): 92 | app.add_routes(RouteManager.create_routes('flow/linker', LINKER_PATH)) 93 | if FLOW_PATH.is_dir(): 94 | app.add_routes(RouteManager.create_routes('flow', FLOW_PATH)) 95 | 96 | @staticmethod 97 | def _load_config(conf_file: Path) -> Dict[str, Any]: 98 | try: 99 | with conf_file.open('r') as f: 100 | return json.load(f) 101 | except json.JSONDecodeError as e: 102 | logger.error(f"{FLOWMSG}: Invalid JSON in {conf_file}: {e}") 103 | return {} 104 | except Exception as e: 105 | logger.error(f"{FLOWMSG}: Error loading config from {conf_file}: {e}") 106 | return {} 107 | 108 | -------------------------------------------------------------------------------- /flow/flow_node.py: -------------------------------------------------------------------------------- 1 | class Flow: 2 | def __init__(self): 3 | pass 4 | 5 | @classmethod 6 | def INPUT_TYPES(s): 7 | return { 8 | "required": { 9 | }, 10 | } 11 | 12 | RETURN_TYPES = ("Flow",) 13 | FUNCTION = "flow" 14 | CATEGORY = '🅓 diSty/Flow' 15 | def flow(self): 16 | return "Flow" 17 | 18 | NODE_CLASS_MAPPINGS = { 19 | "Flow": Flow 20 | } 21 | 22 | NODE_DISPLAY_NAME_MAPPINGS = { 23 | "Flow": "Flow" 24 | } -------------------------------------------------------------------------------- /flow/route_manager.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from pathlib import Path 3 | 4 | class RouteManager: 5 | 6 | @staticmethod 7 | def create_routes(base_path: str, app_dir: Path) -> web.RouteTableDef: 8 | routes = web.RouteTableDef() 9 | index_html = app_dir / 'index.html' 10 | 11 | @routes.get(f"/{base_path}") 12 | async def serve_html(request: web.Request) -> web.FileResponse: 13 | return web.FileResponse(index_html) 14 | 15 | for static_dir in ['css', 'js', 'media']: 16 | static_path = app_dir / static_dir 17 | if static_path.is_dir(): 18 | routes.static(f"/{static_dir}/", path=static_path) 19 | 20 | routes.static(f"/{base_path}/", path=app_dir, show_index=False) 21 | return routes 22 | -------------------------------------------------------------------------------- /flow/server_setup.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | import server 3 | from .flow_manager import FlowManager 4 | from .downloader import download_update_flows 5 | from .constants import FLOWMSG, logger 6 | 7 | def setup_server() -> None: 8 | try: 9 | server_instance = server.PromptServer.instance 10 | except Exception as e: 11 | logger.error(f"{FLOWMSG}: Failed to get server instance: {e}") 12 | return 13 | 14 | download_update_flows() 15 | 16 | try: 17 | FlowManager.setup_app_routes(server_instance.app) 18 | except Exception as e: 19 | logger.error(f"{FLOWMSG}: Failed to set up app routes: {e}") 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui-disty-flow" 3 | description = "Flow is a custom node designed to provide a more user-friendly interface for ComfyUI by acting as an alternative user interface for running workflows. It is not a replacement for workflow creation.\nFlow is currently in the early stages of development, so expect bugs and ongoing feature enhancements. With your support and feedback, Flow will settle into a steady stream." 4 | version = "0.5.2" 5 | license = {file = "LICENSE"} 6 | 7 | [project.urls] 8 | Repository = "https://github.com/diStyApps/ComfyUI-disty-Flow" 9 | # Used by Comfy Registry https://comfyregistry.org 10 | 11 | [tool.comfy] 12 | PublisherId = "disty" 13 | DisplayName = "ComfyUI-disty-Flow" 14 | Icon = "" 15 | -------------------------------------------------------------------------------- /web/core/content.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 | 16 | 17 |
18 |
19 | 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | 33 | 47 |
48 | 49 |
50 | 51 | 52 | -------------------------------------------------------------------------------- /web/core/js/common/components/BlobMessageProcessor.js: -------------------------------------------------------------------------------- 1 | import { IMessageProcessor } from './IMessageProcessor.js'; 2 | import { hideSpinner } from './utils.js'; 3 | import { detectMimeType } from './mimeTypeDetector.js'; 4 | 5 | export class BlobMessageProcessor extends IMessageProcessor { 6 | constructor(messageHandler) { 7 | super(); 8 | this.messageHandler = messageHandler; 9 | } 10 | 11 | async process(blob) { 12 | try { 13 | let result = {}; 14 | 15 | if (!blob.type) { 16 | const headerSize = 8; 17 | if (blob.size <= headerSize) { 18 | console.error('Blob size is too small to contain valid image data.'); 19 | hideSpinner(); 20 | result.error = 'Blob size is too small to contain valid image data.'; 21 | return result; 22 | } 23 | 24 | const slicedBlob = blob.slice(headerSize); 25 | const detectedType = await detectMimeType(slicedBlob); 26 | const objectURL = URL.createObjectURL(slicedBlob); 27 | if (detectedType) { 28 | result = { 29 | objectURL, 30 | detectedType, 31 | isTypeDetected: true 32 | }; 33 | } else { 34 | console.error('Could not detect MIME type of Blob.'); 35 | hideSpinner(); 36 | result.error = 'Could not detect MIME type of Blob.'; 37 | } 38 | return result; 39 | } 40 | 41 | if (blob.type.startsWith('image/') || blob.type.startsWith('video/')) { 42 | const objectURL = URL.createObjectURL(blob); 43 | result = { 44 | objectURL, 45 | detectedType: blob.type, 46 | isTypeDetected: true 47 | }; 48 | } else { 49 | console.error('Unsupported Blob type:', blob.type); 50 | hideSpinner(); 51 | result.error = 'Unsupported Blob type: ' + blob.type; 52 | } 53 | return result; 54 | } catch (error) { 55 | console.error('Error processing Blob message:', error); 56 | hideSpinner(); 57 | return { error }; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /web/core/js/common/components/DataComponent.js: -------------------------------------------------------------------------------- 1 | import { store } from '../scripts/stateManagerMain.js'; 2 | import { updateWorkflow } from './workflowManager.js'; 3 | 4 | function getNestedValue(obj, path) { 5 | // console.log('getNestedValue', obj, path); 6 | return path.split('.').reduce((acc, key) => (acc && acc[key] !== undefined) ? acc[key] : undefined, obj); 7 | } 8 | 9 | class DataComponent { 10 | constructor(config, workflow) { 11 | this.id = config.id; 12 | this.name = config.name; 13 | this.nodePath = config.nodePath; 14 | this.dataPath = config.dataPath; 15 | this.workflow = workflow; 16 | 17 | this.handleStoreUpdate = this.handleStoreUpdate.bind(this); 18 | 19 | this.updateWorkflowWithStoreData(); 20 | 21 | this.unsubscribe = store.subscribe(this.handleStoreUpdate); 22 | } 23 | 24 | updateWorkflowWithStoreData() { 25 | const state = store.getState(); 26 | const data = getNestedValue(state, this.dataPath); 27 | 28 | if (data === undefined) { 29 | // console.warn(`DataComponent [${this.id}]: No data found at path "${this.dataPath}" in the store.`); 30 | return; 31 | } 32 | 33 | updateWorkflow(this.workflow, this.nodePath, data); 34 | // console.log(`DataComponent [${this.id}]: Updated workflow at "${this.nodePath}" with data from "${this.dataPath}".`); 35 | } 36 | 37 | handleStoreUpdate() { 38 | this.updateWorkflowWithStoreData(); 39 | } 40 | 41 | destroy() { 42 | if (this.unsubscribe) { 43 | this.unsubscribe(); 44 | // console.log(`DataComponent [${this.id}]: Unsubscribed from store updates.`); 45 | } 46 | } 47 | } 48 | 49 | export default DataComponent; 50 | -------------------------------------------------------------------------------- /web/core/js/common/components/DimSelector.js: -------------------------------------------------------------------------------- 1 | import { updateWorkflow } from './workflowManager.js'; 2 | 3 | class DimensionSelector { 4 | constructor(config = {}, workflow) { 5 | this.id = config.id; 6 | this.workflow = workflow; 7 | this.currentSelection = 'Custom'; 8 | this.config = { 9 | defaultWidth: 1216, 10 | defaultHeight: 832, 11 | minDimension: 32, 12 | maxDimension: 4096, 13 | step: 16, 14 | aspectRatios: [ 15 | { value: 'Custom', label: 'Custom' }, 16 | { value: 'SD1.5', label: 'SD1.5' }, 17 | { value: '512x512', label: '1:1 square 512x512' }, 18 | { value: '1024x1024', label: '1:1 square 1024x1024' }, 19 | { value: '512x768', label: '2:3 portrait 512x768' }, 20 | { value: '512x682', label: '3:4 portrait 512x682' }, 21 | { value: '768x512', label: '3:2 landscape 768x512' }, 22 | { value: '682x512', label: '4:3 landscape 682x512' }, 23 | { value: '910x512', label: '16:9 cinema 910x512' }, 24 | { value: '952x512', label: '1.85:1 cinema 952x512' }, 25 | { value: '1024x512', label: '2:1 cinema 1024x512' }, 26 | { value: '1224x512', label: '2.39:1 anamorphic 1224x512' }, 27 | { value: 'SDXL', label: 'SDXL' }, 28 | { value: '1024x1024', label: '1:1 square 1024x1024' }, 29 | { value: '896x1152', label: '3:4 portrait 896x1152' }, 30 | { value: '832x1216', label: '5:8 portrait 832x1216' }, 31 | { value: '768x1344', label: '9:16 portrait 768x1344' }, 32 | { value: '640x1536', label: '9:21 portrait 640x1536' }, 33 | { value: '1152x896', label: '4:3 landscape 1152x896' }, 34 | { value: '1216x832', label: '3:2 landscape 1216x832' }, 35 | { value: '1344x768', label: '16:9 landscape 1344x768' }, 36 | { value: '1536x640', label: '21:9 landscape 1536x640' }, 37 | ], 38 | nodePath : config.nodePath, 39 | ...config 40 | }; 41 | this.config.defaultWidth = config.defaultWidth || 1216; 42 | this.config.defaultHeight = config.defaultHeight || 832; 43 | 44 | this.initializeComponent(); 45 | } 46 | 47 | initializeComponent() { 48 | if (document.readyState === 'loading') { 49 | document.addEventListener('DOMContentLoaded', () => this.renderComponent()); 50 | } else { 51 | this.renderComponent(); 52 | } 53 | } 54 | 55 | renderComponent() { 56 | this.container = document.getElementById(this.id); 57 | if (this.container) { 58 | this.render(); 59 | this.attachEventListeners(); 60 | this.updateWorkflowWithCurrentDimensions(); 61 | } else { 62 | console.warn(`Container with id "${this.id}" not found. Retrying in 500ms.`); 63 | setTimeout(() => this.renderComponent(), 500); 64 | } 65 | } 66 | 67 | render() { 68 | this.container.innerHTML = ` 69 |
70 |
71 |
72 | 73 |
74 | 75 | 76 | 77 |
78 |
79 | 80 |
81 | 82 |
83 | 84 | 85 | 86 |
87 |
88 |
89 |
90 | 93 |
94 |
95 | `; 96 | } 97 | 98 | attachEventListeners() { 99 | const widthInput = document.getElementById('width-input'); 100 | const heightInput = document.getElementById('height-input'); 101 | const swapBtn = document.getElementById('swap-btn'); 102 | const aspectRatioSelector = document.getElementById('aspect-ratio-selector'); 103 | 104 | this.container.querySelectorAll('.stepper__button').forEach(button => { 105 | button.addEventListener('click', this.handleStepperClick.bind(this)); 106 | }); 107 | 108 | widthInput.addEventListener('change', this.handleInputChange.bind(this)); 109 | heightInput.addEventListener('change', this.handleInputChange.bind(this)); 110 | swapBtn.addEventListener('click', this.handleSwap.bind(this)); 111 | aspectRatioSelector.addEventListener('change', this.handleAspectRatioChange.bind(this)); 112 | } 113 | 114 | handleStepperClick(event) { 115 | const targetInput = document.getElementById(event.target.dataset.target); 116 | const change = event.target.dataset.action === 'increase' ? 1 : -1; 117 | this.updateInputValue(targetInput, change); 118 | } 119 | 120 | handleInputChange(event) { 121 | const dimension = event.target.name; 122 | const value = parseInt(event.target.value, 10); 123 | this.updateWorkflowDimension(dimension, value); 124 | this.currentSelection = 'Custom'; 125 | document.getElementById('aspect-ratio-selector').value = 'Custom'; 126 | } 127 | 128 | handleSwap() { 129 | const widthInput = document.getElementById('width-input'); 130 | const heightInput = document.getElementById('height-input'); 131 | [widthInput.value, heightInput.value] = [heightInput.value, widthInput.value]; 132 | this.updateWorkflowWithCurrentDimensions(); 133 | this.currentSelection = 'Custom'; 134 | document.getElementById('aspect-ratio-selector').value = 'Custom'; 135 | } 136 | 137 | handleAspectRatioChange(event) { 138 | const value = event.target.value; 139 | this.currentSelection = value; 140 | 141 | if (value === 'Custom') { 142 | this.updateWorkflowWithCurrentDimensions(); 143 | } else { 144 | const [width, height] = value.split('x').map(Number); 145 | this.updateWorkflowDimension('width', width); 146 | this.updateWorkflowDimension('height', height); 147 | } 148 | } 149 | 150 | updateInputValue(input, change) { 151 | const min = parseInt(input.getAttribute('min'), 10); 152 | const max = parseInt(input.getAttribute('max'), 10); 153 | const step = parseInt(input.getAttribute('step'), 10); 154 | let newValue = parseInt(input.value, 10) + change * step; 155 | newValue = Math.max(min, Math.min(newValue, max)); 156 | input.value = newValue; 157 | this.updateWorkflowDimension(input.name, newValue); 158 | this.currentSelection = 'Custom'; 159 | document.getElementById('aspect-ratio-selector').value = 'Custom'; 160 | } 161 | 162 | updateWorkflowDimension(dimension, value) { 163 | const path = this.config.nodePath; 164 | // const path = config.nodePath 165 | updateWorkflow(this.workflow, `${path}.${dimension}`, value); 166 | // console.log(`Workflow updated - ${dimension}: ${value}`); 167 | } 168 | 169 | updateWorkflowWithCurrentDimensions() { 170 | const width = parseInt(document.getElementById('width-input').value, 10); 171 | const height = parseInt(document.getElementById('height-input').value, 10); 172 | this.updateWorkflowDimension('width', width); 173 | this.updateWorkflowDimension('height', height); 174 | } 175 | } 176 | export default DimensionSelector; 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /web/core/js/common/components/Dropdown.js: -------------------------------------------------------------------------------- 1 | import { populateDropdown } from './dropdownHandler.js'; 2 | 3 | export default class Dropdown { 4 | constructor(config, workflow) { 5 | this.config = config; 6 | this.workflow = workflow; 7 | this.loaderContainer = document.getElementById(config.id); 8 | if (this.loaderContainer) { 9 | this.loadDropdownData(); 10 | } 11 | } 12 | 13 | loadDropdownData() { 14 | const BASE_URL = `${window.location.origin}/object_info`; 15 | const url = `${BASE_URL}/${this.config.url}`; 16 | 17 | fetch(url) 18 | .then(response => { 19 | if (!response.ok) { 20 | throw new Error('Network response was not ok ' + response.statusText); 21 | } 22 | return response.json(); 23 | }) 24 | .then(data => { 25 | this.populate(data); 26 | }) 27 | .catch(error => { 28 | console.error(`Error loading data for ${this.config.id}:`, error); 29 | this.displayMissingComponentMessage(); 30 | }); 31 | } 32 | 33 | populate(data) { 34 | const firstKey = Object.keys(data)[0]; 35 | const loaderData = data[firstKey]; 36 | 37 | let inputData = loaderData.input.required[this.config.key]; 38 | if (!inputData) { 39 | console.warn( 40 | `Required input for key "${this.config.key}" is missing. Checking optional input.` 41 | ); 42 | inputData = loaderData.input.optional 43 | ? loaderData.input.optional[this.config.key] 44 | : undefined; 45 | } 46 | 47 | if (!inputData) { 48 | this.displayMissingComponentMessage(); 49 | return; 50 | } 51 | 52 | populateDropdown( 53 | this.config.id, 54 | inputData[0], 55 | this.config.label, 56 | this.config.nodePath, 57 | this.workflow 58 | ); 59 | } 60 | 61 | displayMissingComponentMessage() { 62 | if (this.loaderContainer) { 63 | this.loaderContainer.innerHTML = ''; 64 | const messageElement = document.createElement('div'); 65 | messageElement.style.fontWeight = 'bold'; 66 | messageElement.style.textAlign = 'left'; 67 | messageElement.style.display = 'block'; 68 | this.loaderContainer.style.display = 'block'; 69 | this.loaderContainer.style.textAlign = 'left'; 70 | messageElement.textContent = `Missing custom node: ${this.config.id}`; 71 | this.loaderContainer.appendChild(messageElement); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /web/core/js/common/components/DropdownStepper.js: -------------------------------------------------------------------------------- 1 | import Dropdown from './Dropdown.js'; 2 | import StepperComponent from './Stepper.js'; 3 | function fixUrlPathsParam(originalUrl) { 4 | if (!originalUrl) return originalUrl; 5 | 6 | const idx = originalUrl.indexOf('?paths='); 7 | if (idx === -1) { 8 | return originalUrl; 9 | } 10 | 11 | const baseUrl = originalUrl.slice(0, idx + 7); 12 | const pathsPart = originalUrl.slice(idx + 7); 13 | let decoded = decodeURIComponent(pathsPart); 14 | const items = decoded.split(','); 15 | const cleanedItems = items.map(item => { 16 | return encodeURIComponent(item) 17 | .replace(/%2F/g, '/'); 18 | }); 19 | 20 | const final = cleanedItems.join(','); 21 | const fixedUrl = baseUrl + final; 22 | return fixedUrl; 23 | } 24 | 25 | class DropdownStepper { 26 | constructor(config, workflow) { 27 | this.config = config; 28 | this.workflow = workflow; 29 | this.container = document.getElementById(config.id); 30 | 31 | if (!this.container) { 32 | console.error("Container not found:", config.id); 33 | return; 34 | } 35 | 36 | this.createDropdown(); 37 | this.createSteppers(); 38 | } 39 | 40 | createDropdown() { 41 | const dropdownContainer = document.createElement('div'); 42 | dropdownContainer.id = `${this.config.id}-dropdown`; 43 | dropdownContainer.className = 'dropdown-container'; 44 | this.container.appendChild(dropdownContainer); 45 | 46 | const dropdownConfig = { 47 | ...this.config.dropdown, 48 | id: dropdownContainer.id, 49 | label: this.config.label 50 | }; 51 | 52 | if (dropdownConfig.url) { 53 | dropdownConfig.url = fixUrlPathsParam(dropdownConfig.url); 54 | } 55 | new Dropdown(dropdownConfig, this.workflow); 56 | } 57 | 58 | createSteppers() { 59 | this.steppers = []; 60 | const steppersContainer = document.createElement('div'); 61 | steppersContainer.className = 'steppers-container'; 62 | this.container.appendChild(steppersContainer); 63 | 64 | this.config.steppers.forEach(stepperConfig => { 65 | const stepperId = `${this.config.id}-${stepperConfig.id}`; 66 | const stepperContainer = document.createElement('div'); 67 | stepperContainer.id = stepperId; 68 | stepperContainer.className = 'stepper-container'; 69 | steppersContainer.appendChild(stepperContainer); 70 | const stepperConfigWithDefaults = { 71 | ...stepperConfig, 72 | id: stepperContainer.id, 73 | precision: 74 | stepperConfig.precision !== undefined 75 | ? stepperConfig.precision 76 | : 2, 77 | scaleFactor: 78 | stepperConfig.scaleFactor !== undefined 79 | ? stepperConfig.scaleFactor 80 | : Math.pow( 81 | 10, 82 | stepperConfig.precision !== undefined 83 | ? stepperConfig.precision 84 | : 2 85 | ) 86 | }; 87 | 88 | const stepper = new StepperComponent(stepperConfigWithDefaults, this.workflow); 89 | this.steppers.push(stepper); 90 | }); 91 | } 92 | } 93 | 94 | export default DropdownStepper; 95 | -------------------------------------------------------------------------------- /web/core/js/common/components/IMessageProcessor.js: -------------------------------------------------------------------------------- 1 | export class IMessageProcessor { 2 | process(data) { 3 | throw new Error('Method not implemented.'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /web/core/js/common/components/ImageLoader.js: -------------------------------------------------------------------------------- 1 | import { showSpinner, hideSpinner } from './utils.js'; 2 | 3 | export default class ImageLoader { 4 | static DEFAULT_CONFIG = { 5 | allowedFileType: 'video', 6 | defaultImageSrc: '/core/media/ui/drop_image_rect_no_border_trans.png', 7 | showIndicator: false, 8 | }; 9 | 10 | constructor(containerId, config = {}, onImageLoaded = null) { 11 | this.containerId = containerId; 12 | this.config = { ...ImageLoader.DEFAULT_CONFIG, ...config }; 13 | this.onImageLoaded = onImageLoaded; 14 | this.imageDropArea = null; 15 | this.init(); 16 | } 17 | 18 | init() { 19 | this.container = document.getElementById(this.containerId); 20 | if (!this.container) { 21 | console.error(`Container with ID ${this.containerId} not found.`); 22 | return; 23 | } 24 | 25 | if (this.imageDropArea) { 26 | this.destroy(); 27 | } 28 | 29 | this.imageDropArea = document.createElement('div'); 30 | this.imageDropArea.classList.add('image-loader'); 31 | 32 | this.imgElement = document.createElement('img'); 33 | this.imgElement.src = this.config.defaultImageSrc; 34 | this.setElementStyles(this.imgElement); 35 | 36 | this.imageDropArea.appendChild(this.imgElement); 37 | this.container.appendChild(this.imageDropArea); 38 | 39 | this.fileInputElement = document.createElement('input'); 40 | this.fileInputElement.type = 'file'; 41 | // this.fileInputElement.accept = `${this.config.allowedFileType}/*`; 42 | this.fileInputElement.style.display = 'none'; 43 | 44 | this.container.appendChild(this.fileInputElement); 45 | 46 | this.setupEventListeners(); 47 | } 48 | 49 | destroy() { 50 | if (this.imageDropArea) { 51 | this.removeEventListeners(); 52 | this.imageDropArea.remove(); 53 | this.imageDropArea = null; 54 | this.imgElement = null; 55 | if (this.fileInputElement) { 56 | this.fileInputElement.remove(); 57 | this.fileInputElement = null; 58 | } 59 | } 60 | } 61 | 62 | setElementStyles(element) { 63 | Object.assign(element.style, { 64 | maxWidth: '100%', 65 | maxHeight: '100%', 66 | width: 'auto', 67 | height: 'auto', 68 | position: 'absolute', 69 | top: '50%', 70 | left: '50%', 71 | transform: 'translate(-50%, -50%)', 72 | objectFit: 'contain', 73 | }); 74 | } 75 | 76 | setupEventListeners() { 77 | this.handleDragEnter = (e) => this.onDragEnter(e); 78 | this.handleDragOver = (e) => this.onDragOver(e); 79 | this.handleDragLeave = (e) => this.onDragLeave(e); 80 | this.handleDrop = (e) => this.onDrop(e); 81 | 82 | ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => { 83 | this.imageDropArea.addEventListener(eventName, this.preventDefaults, false); 84 | }); 85 | 86 | this.imageDropArea.addEventListener('dragenter', this.handleDragEnter, false); 87 | this.imageDropArea.addEventListener('dragover', this.handleDragOver, false); 88 | this.imageDropArea.addEventListener('dragleave', this.handleDragLeave, false); 89 | this.imageDropArea.addEventListener('drop', this.handleDrop, false); 90 | 91 | this.handleDoubleClick = (e) => this.onDoubleClick(e); 92 | this.imageDropArea.addEventListener('dblclick', this.handleDoubleClick, false); 93 | 94 | this.handleFileInputChange = (e) => this.onFileInputChange(e); 95 | this.fileInputElement.addEventListener('change', this.handleFileInputChange, false); 96 | } 97 | 98 | removeEventListeners() { 99 | ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => { 100 | this.imageDropArea.removeEventListener(eventName, this.preventDefaults, false); 101 | }); 102 | 103 | this.imageDropArea.removeEventListener('dragenter', this.handleDragEnter, false); 104 | this.imageDropArea.removeEventListener('dragover', this.handleDragOver, false); 105 | this.imageDropArea.removeEventListener('dragleave', this.handleDragLeave, false); 106 | this.imageDropArea.removeEventListener('drop', this.handleDrop, false); 107 | 108 | this.imageDropArea.removeEventListener('dblclick', this.handleDoubleClick, false); 109 | 110 | if (this.fileInputElement) { 111 | this.fileInputElement.removeEventListener('change', this.handleFileInputChange, false); 112 | } 113 | } 114 | 115 | preventDefaults(e) { 116 | e.preventDefault(); 117 | e.stopPropagation(); 118 | } 119 | 120 | onDragEnter(e) { 121 | this.imageDropArea.classList.add('highlight'); 122 | } 123 | 124 | onDragOver(e) { 125 | this.imageDropArea.classList.add('highlight'); 126 | } 127 | 128 | onDragLeave(e) { 129 | this.imageDropArea.classList.remove('highlight'); 130 | } 131 | 132 | onDrop(e) { 133 | this.imageDropArea.classList.remove('highlight'); 134 | const file = e.dataTransfer.files[0]; 135 | if (file) { 136 | this.handleFile(file); 137 | } 138 | } 139 | 140 | onDoubleClick(e) { 141 | this.fileInputElement.click(); 142 | } 143 | 144 | onFileInputChange(e) { 145 | const file = e.target.files[0]; 146 | if (file) { 147 | this.handleFile(file); 148 | } 149 | e.target.value = ''; 150 | } 151 | 152 | async handleFile(file) { 153 | // if (!this.isFileTypeAllowed(file)) { 154 | // this.showErrorMessage(`Unsupported file type. Please select a ${this.config.allowedFileType} file.`); 155 | // return; 156 | // } 157 | 158 | showSpinner(); 159 | 160 | try { 161 | const localSrc = URL.createObjectURL(file); 162 | this.displayMedia(file, localSrc); 163 | const result = await this.uploadFile(file); 164 | this.handleUploadSuccess(localSrc, result); 165 | } catch (error) { 166 | this.handleUploadError(error); 167 | } finally { 168 | hideSpinner(); 169 | } 170 | } 171 | 172 | // isFileTypeAllowed(file) { 173 | // return file.type.startsWith(`${this.config.allowedFileType}/`); 174 | // } 175 | 176 | showErrorMessage(message) { 177 | console.error(message); 178 | alert(message); 179 | } 180 | 181 | displayMedia(file, src) { 182 | if (file.type.startsWith('image/')) { 183 | this.displayImage(src); 184 | } else if (file.type.startsWith('video/')) { 185 | this.displayVideo(src); 186 | } 187 | } 188 | 189 | displayImage(src) { 190 | if (!(this.imgElement instanceof HTMLImageElement)) { 191 | const newImgElement = document.createElement('img'); 192 | this.setElementStyles(newImgElement); 193 | this.imageDropArea.replaceChild(newImgElement, this.imgElement); 194 | this.imgElement = newImgElement; 195 | } 196 | this.imgElement.src = src; 197 | this.imgElement.alt = 'Loaded Image'; 198 | } 199 | 200 | displayVideo(src) { 201 | if (!(this.imgElement instanceof HTMLVideoElement)) { 202 | const videoElement = document.createElement('video'); 203 | videoElement.controls = true; 204 | videoElement.autoplay = true; 205 | this.setElementStyles(videoElement); 206 | this.imageDropArea.replaceChild(videoElement, this.imgElement); 207 | this.imgElement = videoElement; 208 | } 209 | this.imgElement.src = src; 210 | } 211 | 212 | async uploadFile(file) { 213 | const formData = new FormData(); 214 | formData.append('image', file); 215 | 216 | const response = await fetch('/upload/image', { 217 | method: 'POST', 218 | body: formData, 219 | }); 220 | 221 | if (!response.ok) { 222 | const result = await response.json(); 223 | throw new Error(result.message || 'Upload failed'); 224 | } 225 | 226 | return response.json(); 227 | } 228 | 229 | handleUploadSuccess(localSrc, result) { 230 | if (typeof this.onImageLoaded === 'function') { 231 | this.onImageLoaded(localSrc, result); 232 | } 233 | } 234 | 235 | handleUploadError(error) { 236 | console.error('Error during upload', error); 237 | this.showErrorMessage(`Error during upload: ${error.message}`); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /web/core/js/common/components/InputComponent.js: -------------------------------------------------------------------------------- 1 | class InputComponent { 2 | 3 | constructor(config, workflow) { 4 | this.container = document.getElementById(config.id); 5 | if (!this.container) { 6 | console.error("Container not found:", config.id); 7 | return; 8 | } 9 | 10 | this.config = config; 11 | this.workflow = workflow; 12 | this.setupInput(); 13 | this.createInput(); 14 | this.initializeUI(); 15 | } 16 | 17 | setupInput() { 18 | this.type = this.config.type || 'text'; 19 | this.defaultValue = this.config.defValue !== undefined ? this.config.defValue : ''; 20 | this.placeholder = this.config.placeholder || ''; 21 | this.value = this.defaultValue; 22 | } 23 | 24 | createInput() { 25 | const html = ` 26 | 27 | 34 | `; 35 | this.container.innerHTML = html; 36 | 37 | this.inputElement = document.getElementById(`${this.config.id}Input`); 38 | this.labelElement = document.getElementById(`${this.config.id}Label`); 39 | 40 | this.attachEventListeners(); 41 | } 42 | 43 | initializeUI() { 44 | this.updateDisplay(); 45 | this.updateExternalConfig(); 46 | } 47 | 48 | updateValue(newValue) { 49 | this.value = newValue; 50 | this.updateDisplay(); 51 | this.updateExternalConfig(); 52 | } 53 | 54 | updateDisplay() { 55 | if (this.inputElement) { 56 | this.inputElement.value = this.value; 57 | } 58 | } 59 | 60 | updateExternalConfig() { 61 | if (!this.config.nodePath) { 62 | console.warn("nodePath is not defined in config for InputComponent:", this.config.id); 63 | return; 64 | } 65 | 66 | const pathParts = this.config.nodePath.split("."); 67 | let target = this.workflow; 68 | for (let i = 0; i < pathParts.length - 1; i++) { 69 | target = target[pathParts[i]] = target[pathParts[i]] || {}; 70 | } 71 | target[pathParts[pathParts.length - 1]] = this.value; 72 | } 73 | 74 | attachEventListeners() { 75 | if (this.inputElement) { 76 | this.inputElement.addEventListener('input', (event) => { 77 | this.updateValue(event.target.value); 78 | }); 79 | } 80 | } 81 | 82 | escapeHtml(unsafe) { 83 | return String(unsafe) 84 | .replace(/&/g, "&") 85 | .replace(//g, ">") 87 | .replace(/"/g, """) 88 | .replace(/'/g, "'"); 89 | } 90 | } 91 | 92 | export default InputComponent; 93 | -------------------------------------------------------------------------------- /web/core/js/common/components/JSONMessageProcessor.js: -------------------------------------------------------------------------------- 1 | import { IMessageProcessor } from './IMessageProcessor.js'; 2 | import { hideSpinner } from './utils.js'; 3 | 4 | export class JSONMessageProcessor extends IMessageProcessor { 5 | constructor(messageHandler) { 6 | super(); 7 | this.messageHandler = messageHandler; 8 | } 9 | 10 | async process(jsonString) { 11 | try { 12 | const data = JSON.parse(jsonString); 13 | switch (data.type) { 14 | case 'progress': 15 | this.messageHandler.handleProgress(data.data); 16 | break; 17 | case 'crystools.monitor': 18 | this.messageHandler.handleMonitor(data.data); 19 | break; 20 | case 'executed': 21 | this.messageHandler.handleExecuted(data.data); 22 | break; 23 | case 'execution_interrupted': 24 | this.messageHandler.handleInterrupted(); 25 | break; 26 | case 'status': 27 | this.messageHandler.handleStatus(); 28 | break; 29 | case 'executing': 30 | case 'execution_start': 31 | case 'execution_cached': 32 | case 'execution_success': 33 | break; 34 | case 'execution_error': 35 | hideSpinner(); 36 | break; 37 | default: 38 | console.log('Unhandled message type:', data.type); 39 | } 40 | } catch (error) { 41 | console.error('Error parsing JSON message:', error); 42 | hideSpinner(); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /web/core/js/common/components/LoraWorkflowManager.js: -------------------------------------------------------------------------------- 1 | 2 | import WorkflowNodeAdder from './WorkflowNodeAdder.js'; 3 | import DropdownStepper from './DropdownStepper.js'; 4 | 5 | class LoraWorkflowManager { 6 | 7 | constructor(workflow, flowConfig) { 8 | this.workflowManager = new WorkflowNodeAdder(workflow); 9 | this.flowConfig = flowConfig; 10 | this.container = document.getElementById('side-workflow-controls'); 11 | this.addButton = null; 12 | this.initializeUI(); 13 | } 14 | 15 | initializeUI() { 16 | this.addButton = document.createElement('button'); 17 | this.addButton.textContent = '+LoRA'; 18 | this.addButton.classList.add('add-lora-button'); 19 | this.addButton.style.marginBottom = '5px'; 20 | this.container.appendChild(this.addButton); 21 | this.addButton.addEventListener('click', () => this.handleAddLora()); 22 | } 23 | 24 | handleAddLora() { 25 | try { 26 | const modelLoaders = this.workflowManager._findModelLoaders(); 27 | if (modelLoaders.length === 0) { 28 | throw new Error('No model loader found in the workflow to attach LoRA.'); 29 | } 30 | const targetModelLoader = modelLoaders[0]; 31 | const newNodeId = this.workflowManager.addLora(targetModelLoader.id); 32 | const updatedWorkflow = this.workflowManager.getWorkflow(); 33 | 34 | const dynamicConfig = this.createDynamicConfig(newNodeId); 35 | const loraContainer = document.createElement('div'); 36 | loraContainer.id = dynamicConfig.id; 37 | loraContainer.classList.add('dropdown-stepper-container'); 38 | this.container.appendChild(loraContainer); 39 | new DropdownStepper(dynamicConfig, updatedWorkflow); 40 | 41 | // console.log(`LoRA node ${newNodeId} added successfully to model loader ${targetModelLoader.id}.`); 42 | } catch (error) { 43 | console.error('Error adding LoRA:', error); 44 | alert(`Failed to add LoRA: ${error.message}`); 45 | } 46 | } 47 | 48 | createDynamicConfig(nodeId) { 49 | return { 50 | id: `LoraLoader_${nodeId}`, 51 | label: "LoRA", 52 | dropdown: { 53 | url: "LoraLoaderModelOnly", 54 | key: "lora_name", 55 | nodePath: `${nodeId}.inputs.lora_name` 56 | }, 57 | steppers: [ 58 | { 59 | id: `lorastrength_${nodeId}`, 60 | label: "Strength", 61 | minValue: 0, 62 | maxValue: 5, 63 | step: 0.01, 64 | defValue: 1, 65 | precision: 2, 66 | scaleFactor: 1, 67 | container: `lorastrength_container_${nodeId}`, 68 | nodePath: `${nodeId}.inputs.strength_model` 69 | } 70 | ] 71 | }; 72 | } 73 | getWorkflow() { 74 | return this.workflowManager.getWorkflow(); 75 | } 76 | } 77 | 78 | export default LoraWorkflowManager; 79 | -------------------------------------------------------------------------------- /web/core/js/common/components/MultiComponent.js: -------------------------------------------------------------------------------- 1 | import Dropdown from './Dropdown.js'; 2 | import StepperComponent from './Stepper.js'; 3 | import InputComponent from './InputComponent.js'; 4 | import ToggleComponent from './ToggleComponent.js'; 5 | import Seeder from './Seeder.js'; 6 | 7 | class MultiComponent { 8 | constructor(config, workflow) { 9 | this.config = config; 10 | this.workflow = workflow; 11 | this.parentContainer = document.getElementById('side-workflow-controls'); 12 | if (!this.parentContainer) { 13 | console.error("Parent container 'side-workflow-controls' not found."); 14 | return; 15 | } 16 | this.container = null; 17 | this.childComponents = []; 18 | this.initializeComponent(); 19 | } 20 | 21 | initializeComponent() { 22 | 23 | const htmlString = ` 24 |
25 |
26 | ${this.config.label ? `

${this.config.label}

` : ''} 27 | 28 |
29 |
30 | `; 31 | this.parentContainer.insertAdjacentHTML('beforeend', htmlString); 32 | const wrapper = this.parentContainer.querySelector(`#${this.config.id} .multi-component-wrapper`); 33 | this.instantiateChildComponents(wrapper); 34 | } 35 | 36 | instantiateChildComponents(wrapper) { 37 | const componentTypes = ['dropdowns', 'steppers', 'inputs', 'toggles', 'seeders']; 38 | 39 | componentTypes.forEach(type => { 40 | if (this.config[type] && Array.isArray(this.config[type])) { 41 | this.config[type].forEach(componentConfig => { 42 | const componentContainer = document.createElement('div'); 43 | componentContainer.id = componentConfig.id; 44 | componentContainer.classList.add(`${type.slice(0, -1)}-container`); 45 | wrapper.appendChild(componentContainer); 46 | let componentInstance; 47 | switch (type) { 48 | case 'dropdowns': 49 | componentInstance = new Dropdown(componentConfig, this.workflow); 50 | break; 51 | case 'steppers': 52 | componentInstance = new StepperComponent(componentConfig, this.workflow); 53 | break; 54 | case 'inputs': 55 | componentInstance = new InputComponent(componentConfig, this.workflow); 56 | break; 57 | case 'toggles': 58 | componentInstance = new ToggleComponent(componentConfig, this.workflow); 59 | break; 60 | case 'seeders': 61 | componentInstance = new Seeder(componentConfig, this.workflow); 62 | break; 63 | 64 | default: 65 | console.error(`Unknown component type: ${type}`); 66 | } 67 | 68 | if (componentInstance) { 69 | this.childComponents.push(componentInstance); 70 | } 71 | }); 72 | } 73 | }); 74 | } 75 | } 76 | 77 | export default MultiComponent; 78 | -------------------------------------------------------------------------------- /web/core/js/common/components/MultiStepper.js: -------------------------------------------------------------------------------- 1 | import StepperComponent from './Stepper.js'; 2 | 3 | class MultiStepper { 4 | constructor(config, workflow) { 5 | console.log("config:", config.id); 6 | this.container = document.getElementById(config.id); 7 | 8 | if (!this.container) { 9 | console.error("Container not found:", config.id); 10 | return; 11 | } 12 | this.config = config; 13 | this.workflow = workflow; 14 | this.steppers = []; 15 | this.createMultiStepper(); 16 | } 17 | 18 | createMultiStepper() { 19 | const titleHtml = `

${this.config.label}

`; 20 | this.container.innerHTML = titleHtml; 21 | 22 | this.config.steppers.forEach(stepperConfig => { 23 | const stepperId = `${this.config.id}-${stepperConfig.id}`; 24 | console.log("stepperId", stepperId); 25 | const stepperContainer = document.createElement('div'); 26 | stepperContainer.id = stepperId; 27 | stepperContainer.className = 'stepper-container'; 28 | this.container.appendChild(stepperContainer); 29 | 30 | const stepperConfigWithDefaults = { 31 | ...stepperConfig, 32 | id: stepperId, 33 | precision: stepperConfig.precision !== undefined ? stepperConfig.precision : 2, 34 | scaleFactor: stepperConfig.scaleFactor !== undefined ? stepperConfig.scaleFactor : 100 35 | }; 36 | 37 | const stepper = new StepperComponent(stepperConfigWithDefaults, this.workflow); 38 | this.steppers.push(stepper); 39 | }); 40 | } 41 | 42 | getAllValues() { 43 | return this.steppers.map(stepper => ({ 44 | id: stepper.config.id, 45 | value: stepper.value 46 | })); 47 | } 48 | } 49 | 50 | export default MultiStepper; 51 | -------------------------------------------------------------------------------- /web/core/js/common/components/Seeder.js: -------------------------------------------------------------------------------- 1 | class Seeder { 2 | constructor(config, workflow) { 3 | this.container = document.getElementById(config.id); 4 | if (!this.container) { 5 | console.error("Container not found:", config.id); 6 | return; 7 | } 8 | 9 | this.config = { 10 | increment: 1, 11 | ...config 12 | }; 13 | this.config.initialSeed = this.config.initialSeed || Math.floor(Math.random() * 1000000000000000); 14 | this.workflow = workflow; 15 | this.seedGenerator = this.createSeedGenerator(this.config.initialSeed, this.config.increment); 16 | this.autoRandomSeed = true; 17 | this.isPotentialLongPress = false; 18 | 19 | this.buildUI(); 20 | this.initializeUI(); 21 | } 22 | 23 | createSeedGenerator(initialSeed, increment) { 24 | let currentSeed = initialSeed; 25 | return { 26 | next: () => this.modifySeed(currentSeed += increment), 27 | prev: () => this.modifySeed(currentSeed -= increment), 28 | setSeed: (newSeed) => this.modifySeed(currentSeed = newSeed), 29 | reset: () => this.modifySeed(currentSeed = initialSeed) 30 | }; 31 | } 32 | 33 | modifySeed(newSeed) { 34 | this.updateExternalConfig(newSeed); 35 | return newSeed; 36 | } 37 | 38 | buildUI() { 39 | const html = ` 40 | ${this.config.label} 41 | 42 |
43 | 44 | 45 | 46 |
47 | `; 48 | this.container.innerHTML = html; 49 | 50 | this.inputElement = document.getElementById(`${this.config.id}Input`); 51 | this.randomSeedButton = document.getElementById(`${this.config.id}RandomSeedButton`); 52 | this.incrementButton = document.getElementById(`${this.config.id}IncrementButton`); 53 | this.decrementButton = document.getElementById(`${this.config.id}DecrementButton`); 54 | 55 | if (this.autoRandomSeed) { 56 | this.randomSeedButton.classList.add('active'); 57 | } 58 | 59 | this.addEventListeners(); 60 | } 61 | 62 | initializeUI() { 63 | this.inputElement.value = this.seedGenerator.reset(); 64 | } 65 | 66 | addEventListeners() { 67 | let pressTimer; 68 | let isLongPress = false; 69 | const longPressDuration = 150; 70 | 71 | const startPressHandler = (e) => { 72 | e.preventDefault(); 73 | this.isPotentialLongPress = true; 74 | isLongPress = false; 75 | pressTimer = setTimeout(() => { 76 | isLongPress = true; 77 | this.toggleAutoRandomSeed(); 78 | }, longPressDuration); 79 | }; 80 | 81 | const endPressHandler = (e) => { 82 | clearTimeout(pressTimer); 83 | if (this.isPotentialLongPress && !isLongPress) { 84 | this.generateRandomSeed(); 85 | } 86 | this.isPotentialLongPress = false; 87 | }; 88 | 89 | this.randomSeedButton.addEventListener('mousedown', startPressHandler); 90 | this.randomSeedButton.addEventListener('touchstart', startPressHandler); 91 | 92 | this.randomSeedButton.addEventListener('mouseup', endPressHandler); 93 | this.randomSeedButton.addEventListener('touchend', endPressHandler); 94 | 95 | this.randomSeedButton.addEventListener('mouseleave', () => { 96 | clearTimeout(pressTimer); 97 | this.isPotentialLongPress = false; 98 | }); 99 | 100 | this.incrementButton.addEventListener('click', () => { 101 | this.inputElement.value = this.seedGenerator.next(); 102 | this.generateSeed(); 103 | }); 104 | 105 | this.decrementButton.addEventListener('click', () => { 106 | this.inputElement.value = this.seedGenerator.prev(); 107 | this.generateSeed(); 108 | }); 109 | 110 | this.inputElement.addEventListener('input', () => { 111 | const value = parseInt(this.inputElement.value.replace(/[^0-9]/g, ''), 10) || 0; 112 | this.seedGenerator.setSeed(value); 113 | this.inputElement.value = value; 114 | }); 115 | } 116 | 117 | toggleAutoRandomSeed() { 118 | this.autoRandomSeed = !this.autoRandomSeed; 119 | if (this.autoRandomSeed) { 120 | this.randomSeedButton.classList.add('active'); 121 | } else { 122 | this.randomSeedButton.classList.remove('active'); 123 | } 124 | // console.log(`Auto Random Seed is now ${this.autoRandomSeed ? 'ON' : 'OFF'}`); 125 | } 126 | 127 | generateRandomSeed() { 128 | const newSeed = Math.floor(Math.random() * 1000000000000000); 129 | this.seedGenerator.setSeed(newSeed); 130 | this.inputElement.value = newSeed; 131 | // console.log(`Generated new random seed: ${newSeed}`); 132 | } 133 | 134 | generateSeed() { 135 | if (this.autoRandomSeed) { 136 | this.generateRandomSeed(); 137 | } 138 | } 139 | 140 | updateExternalConfig(newSeed) { 141 | const path = this.config.nodePath; 142 | const pathParts = path.split("."); 143 | let target = this.workflow; 144 | for (let i = 0; i < pathParts.length - 1; i++) { 145 | target = target[pathParts[i]] = target[pathParts[i]] || {}; 146 | } 147 | target[pathParts[pathParts.length - 1]] = newSeed; 148 | } 149 | 150 | triggerSeedIfAutoRandom() { 151 | if (this.autoRandomSeed) { 152 | this.generateRandomSeed(); 153 | this.inputElement.value = this.seedGenerator.setSeed(this.seedGenerator.currentSeed); 154 | } 155 | } 156 | } 157 | 158 | export default Seeder; -------------------------------------------------------------------------------- /web/core/js/common/components/Stepper.js: -------------------------------------------------------------------------------- 1 | class StepperComponent { 2 | constructor(config, workflow) { 3 | // console.log("StepperComponent config:", config); 4 | this.container = document.getElementById(config.id); 5 | if (!this.container) { 6 | console.error("Container not found:", config.id); 7 | return; 8 | } 9 | this.config = config; 10 | this.workflow = workflow; 11 | this.setupStepper(); 12 | this.createStepper(); 13 | this.initializeUI(); 14 | } 15 | 16 | setupStepper() { 17 | this.minValue = this.config.minValue; 18 | this.maxValue = this.config.maxValue; 19 | this.step = this.config.step; 20 | this.precision = this.config.precision !== undefined ? this.config.precision : 2; 21 | this.scaleFactor = this.config.scaleFactor !== undefined ? this.config.scaleFactor : Math.pow(10, this.precision); 22 | this.value = parseFloat(this.config.defValue.toFixed(this.precision)); 23 | } 24 | 25 | createStepper() { 26 | const html = ` 27 | ${this.config.label} 28 | 33 | 35 | 36 | 37 | `; 38 | this.container.innerHTML = html; 39 | 40 | this.inputElement = document.getElementById(`${this.config.id}Input`); 41 | this.decrementButton = document.getElementById(`${this.config.id}DecrementButton`); 42 | this.incrementButton = document.getElementById(`${this.config.id}IncrementButton`); 43 | this.sliderElement = document.getElementById(`${this.config.id}SliderInput`); 44 | 45 | this.attachEventListeners(); 46 | } 47 | 48 | initializeUI() { 49 | this.updateDisplay(); 50 | this.updateExternalConfig(); 51 | } 52 | 53 | updateValue(newValue) { 54 | const scaledValue = parseFloat(newValue); 55 | if (scaledValue >= this.minValue && scaledValue <= this.maxValue) { 56 | this.value = parseFloat(scaledValue.toFixed(this.precision)); 57 | this.updateDisplay(); 58 | this.updateExternalConfig(); 59 | } 60 | } 61 | 62 | updateDisplay() { 63 | this.inputElement.value = this.value.toFixed(this.precision); 64 | this.sliderElement.value = parseFloat(this.value) * this.scaleFactor; 65 | } 66 | 67 | updateExternalConfig() { 68 | const path = this.config.nodePath; 69 | const pathParts = path.split("."); 70 | let target = this.workflow; 71 | for (let i = 0; i < pathParts.length - 1; i++) { 72 | target = target[pathParts[i]] = target[pathParts[i]] || {}; 73 | } 74 | target[pathParts[pathParts.length - 1]] = parseFloat(this.value); 75 | } 76 | 77 | increment() { 78 | this.updateValue(parseFloat(this.value) + this.step); 79 | } 80 | 81 | decrement() { 82 | this.updateValue(parseFloat(this.value) - this.step); 83 | } 84 | 85 | attachEventListeners() { 86 | this.incrementButton.addEventListener('click', () => this.increment()); 87 | this.decrementButton.addEventListener('click', () => this.decrement()); 88 | this.sliderElement.addEventListener('input', () => { 89 | this.updateValue(this.sliderElement.value / this.scaleFactor); 90 | }); 91 | this.inputElement.addEventListener('input', () => { 92 | this.updateValue(this.inputElement.value); 93 | }); 94 | } 95 | } 96 | 97 | export default StepperComponent; 98 | -------------------------------------------------------------------------------- /web/core/js/common/components/ToggleComponent.js: -------------------------------------------------------------------------------- 1 | class ToggleComponent { 2 | constructor(config, workflow) { 3 | this.container = document.getElementById(config.id); 4 | if (!this.container) { 5 | console.error("Container not found:", config.id); 6 | return; 7 | } 8 | 9 | this.config = { 10 | label: '', 11 | defaultValue: false, 12 | labelPosition: 'left', 13 | labelIconOn: '', 14 | labelIconOff: '', 15 | ...config 16 | }; 17 | this.workflow = workflow; 18 | this.value = typeof this.config.defaultValue === "boolean" 19 | ? (this.config.defaultValue ? 1 : 0) 20 | : (this.config.defaultValue === "true" ? 1 : 0); 21 | 22 | console.log("ToggleComponent", this.value, this.config.id, this.config.defaultValue); 23 | 24 | this.buildUI(); 25 | this.initializeUI(); 26 | } 27 | 28 | buildUI() { 29 | const toggleId = `${this.config.id}Toggle`; 30 | 31 | const html = ` 32 |
33 | ${this.config.labelPosition === 'left' ? this.getLabelHTML() : ''} 34 |
35 | 45 | 49 |
50 |
51 | ${this.config.labelPosition === 'right' ? this.getLabelHTML() : ''} 52 |
53 | `; 54 | this.container.innerHTML = html; 55 | 56 | this.inputElement = this.container.querySelector(`#${toggleId}`); 57 | this.labelElement = this.container.querySelector('.toggle-label-container'); 58 | 59 | this.attachEventListeners(); 60 | } 61 | 62 | getLabelHTML() { 63 | return ` 64 |
65 | ${this.config.labelIconOff ? `Off Icon` : ''} 66 | ${this.config.label} 67 | ${this.config.labelIconOn ? `On Icon` : ''} 68 |
69 | `; 70 | } 71 | 72 | initializeUI() { 73 | this.updateDisplay(); 74 | this.updateExternalConfig(); 75 | } 76 | 77 | updateValue(newValue) { 78 | this.value = newValue ? 1 : 0; 79 | this.updateDisplay(); 80 | this.updateExternalConfig(); 81 | } 82 | 83 | updateDisplay() { 84 | if (this.inputElement) { 85 | this.inputElement.checked = this.value; 86 | } 87 | 88 | if (this.labelElement) { 89 | const offIcon = this.labelElement.querySelector('.off-icon'); 90 | const onIcon = this.labelElement.querySelector('.on-icon'); 91 | if (offIcon && onIcon) { 92 | offIcon.style.display = this.value ? 'none' : 'inline'; 93 | onIcon.style.display = this.value ? 'inline' : 'none'; 94 | } 95 | } 96 | } 97 | 98 | updateExternalConfig() { 99 | if (!this.config.nodePath) { 100 | console.warn("nodePath is not defined in config for ToggleComponent:", this.config.id); 101 | return; 102 | } 103 | 104 | const pathParts = this.config.nodePath.split("."); 105 | let target = this.workflow; 106 | for (let i = 0; i < pathParts.length - 1; i++) { 107 | target = target[pathParts[i]] = target[pathParts[i]] || {}; 108 | } 109 | target[pathParts[pathParts.length - 1]] = this.value; 110 | } 111 | 112 | attachEventListeners() { 113 | if (this.inputElement) { 114 | this.inputElement.addEventListener('change', (event) => { 115 | this.updateValue(event.target.checked); 116 | if (typeof this.config.onChange === 'function') { 117 | this.config.onChange(this.value); 118 | } 119 | }); 120 | } 121 | } 122 | } 123 | 124 | export default ToggleComponent; 125 | -------------------------------------------------------------------------------- /web/core/js/common/components/WorkflowNodeAdder.js: -------------------------------------------------------------------------------- 1 | class WorkflowNodeAdder { 2 | constructor(workflow) { 3 | if (typeof workflow !== 'object' || workflow === null || Array.isArray(workflow)) { 4 | throw new TypeError('Workflow must be a non-null object'); 5 | } 6 | this.workflow = JSON.parse(JSON.stringify(workflow)); 7 | this.existingIds = new Set(Object.keys(this.workflow).map(id => parseInt(id, 10))); 8 | this.highestId = this._getHighestNodeId(); 9 | } 10 | 11 | addLora(modelLoaderId) { 12 | if (!this.workflow[modelLoaderId]) { 13 | throw new Error(`Model loader node with ID ${modelLoaderId} does not exist.`); 14 | } 15 | 16 | const modelLoader = this.workflow[modelLoaderId]; 17 | if (!this._isModelLoader(modelLoader.class_type)) { 18 | throw new Error(`Node ID ${modelLoaderId} is not a recognized model loader.`); 19 | } 20 | 21 | const newLoraId = this._getNextNodeId(); 22 | const loraNode = this._createLoraNode(newLoraId); 23 | 24 | const existingLoras = this._findLoraNodes(modelLoaderId); 25 | if (existingLoras.length === 0) { 26 | const firstConnectedNodes = this._findConnectedNodes(modelLoaderId); 27 | if (firstConnectedNodes.length === 0) { 28 | throw new Error(`No nodes are directly connected to model loader ID ${modelLoaderId}.`); 29 | } 30 | 31 | firstConnectedNodes.forEach(node => { 32 | node.inputs.model = [newLoraId.toString(), 0]; 33 | }); 34 | 35 | loraNode.inputs.model = [modelLoaderId.toString(), 0]; 36 | } else { 37 | const lastLora = this._getLastLoraNode(existingLoras); 38 | const firstConnectedNodes = this._findConnectedNodes(lastLora.id); 39 | if (firstConnectedNodes.length === 0) { 40 | throw new Error(`No nodes are directly connected to the last LoRA node ID ${lastLora.id}.`); 41 | } 42 | 43 | firstConnectedNodes.forEach(node => { 44 | node.inputs.model = [newLoraId.toString(), 0]; 45 | }); 46 | 47 | loraNode.inputs.model = [lastLora.id.toString(), 0]; 48 | } 49 | 50 | this.workflow[newLoraId.toString()] = loraNode; 51 | this.existingIds.add(newLoraId); 52 | this.highestId = newLoraId; 53 | 54 | return newLoraId; 55 | } 56 | 57 | getWorkflow() { 58 | return this.workflow; 59 | } 60 | 61 | _createLoraNode(id) { 62 | return { 63 | inputs: { 64 | lora_name: "lora.safetensors", 65 | strength_model: 1, 66 | model: [] 67 | }, 68 | class_type: "LoraLoaderModelOnly", 69 | _meta: { 70 | title: "LoraLoaderModelOnly" 71 | } 72 | }; 73 | } 74 | 75 | _findLoraNodes(modelLoaderId) { 76 | return Object.entries(this.workflow) 77 | .filter(([_, node]) => node.class_type === "LoraLoaderModelOnly") 78 | .map(([id, node]) => ({ id: parseInt(id, 10), ...node })) 79 | .filter(lora => { 80 | const modelInput = lora.inputs.model; 81 | return Array.isArray(modelInput) && parseInt(modelInput[0], 10) === modelLoaderId; 82 | }); 83 | } 84 | 85 | _findModelLoaders() { 86 | return Object.entries(this.workflow) 87 | .filter(([_, node]) => { 88 | const hasModelInput = node.inputs && node.inputs.model !== undefined; 89 | return !hasModelInput && this._isModelLoader(node.class_type); 90 | }) 91 | .map(([id, node]) => ({ id: parseInt(id, 10), ...node })); 92 | } 93 | 94 | _isModelLoader(classType) { 95 | const modelLoaderTypes = ["UNETLoader","CheckpointLoaderSimple","DownloadAndLoadMochiModel","UnetLoaderGGUF"]; 96 | return modelLoaderTypes.includes(classType); 97 | } 98 | 99 | _findConnectedNodes(nodeId) { 100 | return Object.entries(this.workflow) 101 | .filter(([_, node]) => { 102 | if (!node.inputs || !node.inputs.model) return false; 103 | const modelInput = node.inputs.model; 104 | return Array.isArray(modelInput) && parseInt(modelInput[0], 10) === nodeId; 105 | }) 106 | .map(([id, node]) => ({ id: parseInt(id, 10), ...node })); 107 | } 108 | 109 | _getLastLoraNode(loraNodes) { 110 | return loraNodes.reduce((prev, current) => { 111 | return (prev.id > current.id) ? prev : current; 112 | }, loraNodes[0]); 113 | } 114 | 115 | _getNextNodeId() { 116 | return this.highestId + 1; 117 | } 118 | 119 | _getHighestNodeId() { 120 | return this.existingIds.size > 0 ? Math.max(...this.existingIds) : 0; 121 | } 122 | } 123 | 124 | export default WorkflowNodeAdder; 125 | -------------------------------------------------------------------------------- /web/core/js/common/components/canvas/CanvasManager.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from './EventEmitter.js'; 2 | import { CanvasPlugin } from './CanvasPlugin.js'; 3 | 4 | export class CanvasManager extends EventEmitter { 5 | constructor(options = {}) { 6 | super(); 7 | this.canvasId = options.canvasId || 'imageCanvas'; 8 | this.canvas = new fabric.Canvas(this.canvasId, { 9 | selection: false, 10 | defaultCursor: 'default', 11 | preserveObjectStacking: true 12 | }); 13 | 14 | this.plugins = new Set(); 15 | 16 | this.init(); 17 | 18 | window.addEventListener('resize', this.resizeCanvas.bind(this)); 19 | 20 | this.observeContainerResize(); 21 | 22 | this.scaleMultiplier = 1; 23 | 24 | this.on('canvas:scaleChanged', this.onScaleChanged.bind(this)); 25 | } 26 | 27 | init() { 28 | this.resizeCanvas(); 29 | this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); 30 | } 31 | 32 | resizeCanvas() { 33 | const container = document.getElementById('canvasWrapper'); 34 | if (!container) { 35 | console.warn('canvasWrapper element not found in the DOM.'); 36 | return; 37 | } 38 | 39 | const { clientWidth, clientHeight } = container; 40 | 41 | this.canvas.setWidth(clientWidth); 42 | this.canvas.setHeight(clientHeight); 43 | 44 | this.canvas.renderAll(); 45 | 46 | this.emit('canvas:resized', { width: clientWidth, height: clientHeight }); 47 | } 48 | 49 | observeContainerResize() { 50 | const container = document.getElementById('canvasWrapper'); 51 | if (!container || typeof ResizeObserver === 'undefined') return; 52 | 53 | const resizeObserver = new ResizeObserver(() => { 54 | this.resizeCanvas(); 55 | }); 56 | 57 | resizeObserver.observe(container); 58 | } 59 | 60 | registerPlugin(plugin) { 61 | if (!(plugin instanceof CanvasPlugin)) { 62 | throw new Error('Plugin must extend the CanvasPlugin class.'); 63 | } 64 | plugin.init(this); 65 | this.plugins.add(plugin); 66 | // console.log(`CanvasManager: Registered plugin ${plugin.name}`); 67 | } 68 | 69 | unregisterPlugin(plugin) { 70 | if (this.plugins.has(plugin)) { 71 | plugin.destroy(); 72 | this.plugins.delete(plugin); 73 | // console.log(`CanvasManager: Unregistered plugin ${plugin.name}`); 74 | } 75 | } 76 | 77 | getCanvasImage() { 78 | return this.canvas.toDataURL({ 79 | format: 'png', 80 | multiplier: this.scaleMultiplier 81 | }); 82 | } 83 | 84 | getPluginByName(name) { 85 | for (let plugin of this.plugins) { 86 | if (plugin.name === name) { 87 | return plugin; 88 | } 89 | } 90 | return null; 91 | } 92 | 93 | onHandleSave() { 94 | const maskBrushPlugin = this.getPluginByName('MaskBrushPlugin'); 95 | if (maskBrushPlugin) { 96 | const selectedOption = maskBrushPlugin.saveOptionsSelect.value; 97 | this.emit('save:trigger', selectedOption); 98 | } else { 99 | console.error('MaskBrushPlugin is not registered. Cannot handle save.'); 100 | } 101 | } 102 | 103 | onScaleChanged(data) { 104 | this.scaleMultiplier = data.scale; 105 | console.log(`CanvasManager: Scale multiplier set to ${this.scaleMultiplier}`); 106 | } 107 | 108 | destroy() { 109 | window.removeEventListener('resize', this.resizeCanvas.bind(this)); 110 | 111 | this.plugins.forEach(plugin => plugin.destroy()); 112 | this.plugins.clear(); 113 | 114 | this.canvas.dispose(); 115 | console.log('CanvasManager destroyed'); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /web/core/js/common/components/canvas/CanvasPlugin.js: -------------------------------------------------------------------------------- 1 | export class CanvasPlugin { 2 | constructor(name) { 3 | this.name = name; 4 | } 5 | 6 | init(canvasManager) { 7 | throw new Error('init() must be implemented by the plugin.'); 8 | } 9 | 10 | destroy() { 11 | throw new Error('destroy() must be implemented by the plugin.'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /web/core/js/common/components/canvas/CanvasScaleForSavePlugin.js: -------------------------------------------------------------------------------- 1 | import { CanvasPlugin } from './CanvasPlugin.js'; 2 | 3 | export class CanvasScaleForSavePlugin extends CanvasPlugin { 4 | constructor(options = {}) { 5 | super('CanvasScaleForSavePlugin'); 6 | 7 | this.increaseScale = this.increaseScale.bind(this); 8 | this.decreaseScale = this.decreaseScale.bind(this); 9 | this.updateScaleDisplay = this.updateScaleDisplay.bind(this); 10 | this.emitScaleChange = this.emitScaleChange.bind(this); 11 | this.downloadCanvasImage = this.downloadCanvasImage.bind(this); 12 | this.onCanvasResized = this.onCanvasResized.bind(this); 13 | 14 | this.defaultScale = options.defaultScale || 1; 15 | this.showDownloadButton = options.showDownloadButton !== undefined ? options.showDownloadButton : true; 16 | 17 | this.scale = this.defaultScale; 18 | 19 | this.uiContainer = null; 20 | this.scaleDecreaseBtn = null; 21 | this.scaleIncreaseBtn = null; 22 | this.scaleValueDisplay = null; 23 | this.actualSizeDisplay = null; 24 | this.downloadBtn = null; 25 | } 26 | 27 | init(canvasManager) { 28 | this.canvasManager = canvasManager; 29 | this.canvas = canvasManager.canvas; 30 | 31 | this.createUI(); 32 | this.attachEventListeners(); 33 | this.updateActualSize(); 34 | } 35 | 36 | createUI() { 37 | const styleSheet = document.createElement('style'); 38 | styleSheet.textContent = ` 39 | .csfs-container * { 40 | box-sizing: border-box; 41 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 42 | } 43 | 44 | .csfs-container { 45 | display: inline-flex; 46 | align-items: center; 47 | gap: 0.5rem; 48 | padding: 0.5rem; 49 | /* background: var(--color-background); */ 50 | /* border: 1px dashed var(--color-border); */ 51 | user-select: none; 52 | margin-left: 1rem; /* Adjust as needed to align with existing controls */ 53 | } 54 | 55 | .csfs-button { 56 | display: inline-flex; 57 | align-items: center; 58 | justify-content: center; 59 | padding: 0.5rem; 60 | background: var(--color-button-primary); 61 | border: none; 62 | cursor: pointer; 63 | transition: all 0.2s; 64 | color: var(--color-primary-text); 65 | min-width: 36px; 66 | height: 36px; 67 | } 68 | 69 | .csfs-button:hover { 70 | background: var(--color-button-primary-hover); 71 | } 72 | 73 | .csfs-button svg { 74 | width: 1.25rem; 75 | height: 1.25rem; 76 | } 77 | 78 | .csfs-button[data-active="true"] { 79 | border: 1px dashed var(--color-button-secondary-text-active); 80 | } 81 | 82 | /* Styles for scale controls */ 83 | .csfs-scale-container { 84 | display: inline-flex; 85 | align-items: center; 86 | gap: 0.25rem; 87 | padding: 0.5rem; 88 | } 89 | 90 | .csfs-scale-label { 91 | margin-right: 0.25rem; 92 | font-weight: bold; 93 | } 94 | 95 | .csfs-scale-display { 96 | margin-left: 0.5rem; 97 | font-size: 0.9rem; 98 | color: var(--color-secondary-text); 99 | font-weight: bold; 100 | 101 | } 102 | .csfs-scale-value { 103 | padding: 8px 8px; 104 | font-weight: bold; 105 | } 106 | `; 107 | document.head.appendChild(styleSheet); 108 | 109 | const temp = document.createElement('div'); 110 | temp.innerHTML = ` 111 |
112 | 113 |
114 | 115 | ${this.showDownloadButton ? ` 116 | 117 | 122 | ` : ''} 123 | 124 | 125 | 1x 126 | 127 | 512x512 128 |
129 |
130 | `; 131 | 132 | this.uiContainer = temp.firstElementChild; 133 | this.scaleDecreaseBtn = this.uiContainer.querySelector('#csfs-scaleDecreaseBtn'); 134 | this.scaleIncreaseBtn = this.uiContainer.querySelector('#csfs-scaleIncreaseBtn'); 135 | this.scaleValueDisplay = this.uiContainer.querySelector('#csfs-scaleValue'); 136 | this.actualSizeDisplay = this.uiContainer.querySelector('#csfs-actualSize'); 137 | this.downloadBtn = this.uiContainer.querySelector('#csfs-downloadBtn'); 138 | 139 | const pluginUIContainer = document.getElementById('pluginUIContainer'); 140 | if (pluginUIContainer) { 141 | pluginUIContainer.appendChild(this.uiContainer); 142 | } else { 143 | console.warn('Element with id "pluginUIContainer" not found.'); 144 | } 145 | } 146 | 147 | attachEventListeners() { 148 | this.scaleIncreaseBtn.addEventListener('click', this.increaseScale); 149 | this.scaleDecreaseBtn.addEventListener('click', this.decreaseScale); 150 | 151 | if (this.downloadBtn) { 152 | this.downloadBtn.addEventListener('click', this.downloadCanvasImage); 153 | } 154 | 155 | this.canvasManager.on('canvas:resized', this.onCanvasResized); 156 | } 157 | 158 | 159 | detachEventListeners() { 160 | this.scaleIncreaseBtn.removeEventListener('click', this.increaseScale); 161 | this.scaleDecreaseBtn.removeEventListener('click', this.decreaseScale); 162 | 163 | if (this.downloadBtn) { 164 | this.downloadBtn.removeEventListener('click', this.downloadCanvasImage); 165 | } 166 | 167 | this.canvasManager.off('canvas:resized', this.onCanvasResized); 168 | } 169 | 170 | increaseScale() { 171 | if (this.scale < 10) { 172 | this.scale += 1; 173 | this.updateScaleDisplay(); 174 | this.emitScaleChange(); 175 | this.updateActualSize(); 176 | } 177 | } 178 | 179 | decreaseScale() { 180 | if (this.scale > 1) { 181 | this.scale -= 1; 182 | this.updateScaleDisplay(); 183 | this.emitScaleChange(); 184 | this.updateActualSize(); 185 | } 186 | } 187 | 188 | updateScaleDisplay() { 189 | this.scaleValueDisplay.textContent = `${this.scale}x`; 190 | } 191 | 192 | emitScaleChange() { 193 | this.canvasManager.emit('canvas:scaleChanged', { scale: this.scale }); 194 | } 195 | 196 | updateActualSize() { 197 | const canvasWidth = this.canvas.getWidth(); 198 | const canvasHeight = this.canvas.getHeight(); 199 | const scaledWidth = Math.round(canvasWidth * this.scale); 200 | const scaledHeight = Math.round(canvasHeight * this.scale); 201 | this.actualSizeDisplay.textContent = `${scaledWidth}x${scaledHeight}`; 202 | } 203 | 204 | onCanvasResized(data) { 205 | this.updateActualSize(); 206 | } 207 | 208 | downloadCanvasImage() { 209 | const dataURL = this.canvasManager.getCanvasImage(); 210 | const link = document.createElement('a'); 211 | link.href = dataURL; 212 | link.download = 'canvas-image.png'; 213 | document.body.appendChild(link); 214 | link.click(); 215 | document.body.removeChild(link); 216 | } 217 | 218 | destroy() { 219 | if (this.uiContainer && this.uiContainer.parentNode) { 220 | this.uiContainer.parentNode.removeChild(this.uiContainer); 221 | } 222 | 223 | this.detachEventListeners(); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /web/core/js/common/components/canvas/EventEmitter.js: -------------------------------------------------------------------------------- 1 | export class EventEmitter { 2 | constructor() { 3 | this.events = {}; 4 | } 5 | 6 | on(event, listener) { 7 | if (!this.events[event]) { 8 | this.events[event] = new Set(); 9 | } 10 | this.events[event].add(listener); 11 | } 12 | 13 | off(event, listener) { 14 | if (!this.events[event]) return; 15 | this.events[event].delete(listener); 16 | } 17 | 18 | emit(event, ...args) { 19 | if (!this.events[event]) return; 20 | for (let listener of this.events[event]) { 21 | listener(...args); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/core/js/common/components/canvas/ImageAdderPlugin.js: -------------------------------------------------------------------------------- 1 | import { CanvasPlugin } from './CanvasPlugin.js'; 2 | 3 | export class ImageAdderPlugin extends CanvasPlugin { 4 | constructor(options = {}) { 5 | super('ImageAdderPlugin'); 6 | 7 | this.options = { 8 | ...options 9 | }; 10 | 11 | this.canvasManager = null; 12 | this.canvas = null; 13 | 14 | this.handleAddImage = this.handleAddImage.bind(this); 15 | } 16 | 17 | init(canvasManager) { 18 | this.canvasManager = canvasManager; 19 | this.canvas = canvasManager.canvas; 20 | 21 | this.createUI(); 22 | 23 | this.attachEventListeners(); 24 | } 25 | 26 | createUI() { 27 | const html = ` 28 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 52 |
53 | `; 54 | 55 | this.uiContainer = document.createElement('div'); 56 | this.uiContainer.innerHTML = html; 57 | this.uiContainer.className = 'image-adder-plugin-container'; 58 | 59 | this.uiContainer.style.padding = '10px'; 60 | this.uiContainer.style.borderRadius = '5px'; 61 | this.uiContainer.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.1)'; 62 | this.uiContainer.style.backgroundColor = 'rgba(255, 255, 255, 0.9)'; 63 | this.uiContainer.style.marginTop = '10px'; 64 | 65 | const pluginUIContainer = document.getElementById('pluginUIContainer'); 66 | if (pluginUIContainer) { 67 | pluginUIContainer.appendChild(this.uiContainer); 68 | } else { 69 | console.warn('pluginUIContainer element not found in the DOM.'); 70 | } 71 | } 72 | 73 | attachEventListeners() { 74 | const addButton = this.uiContainer.querySelector('#add-image-button'); 75 | addButton.addEventListener('click', this.handleAddImage); 76 | } 77 | 78 | detachEventListeners() { 79 | const addButton = this.uiContainer.querySelector('#add-image-button'); 80 | addButton.removeEventListener('click', this.handleAddImage); 81 | } 82 | 83 | handleAddImage() { 84 | const colorPicker = this.uiContainer.querySelector('#image-color-picker'); 85 | const widthInput = this.uiContainer.querySelector('#image-width-input'); 86 | const heightInput = this.uiContainer.querySelector('#image-height-input'); 87 | 88 | const color = colorPicker.value; 89 | const width = parseInt(widthInput.value, 10); 90 | const height = parseInt(heightInput.value, 10); 91 | 92 | if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) { 93 | alert('Please enter valid width and height values.'); 94 | return; 95 | } 96 | 97 | const rect = new fabric.Rect({ 98 | width: width, 99 | height: height, 100 | fill: color, 101 | left: this.canvas.getWidth() / 2, 102 | top: this.canvas.getHeight() / 2, 103 | originX: 'center', 104 | originY: 'center', 105 | selectable: true, 106 | hasControls: true, 107 | hasBorders: true, 108 | }); 109 | 110 | this.canvas.add(rect); 111 | this.canvas.setActiveObject(rect); 112 | this.canvas.requestRenderAll(); 113 | 114 | this.canvasManager.emit('image:added', { 115 | type: 'rectangle', 116 | object: rect, 117 | color: color, 118 | width: width, 119 | height: height, 120 | }); 121 | } 122 | 123 | destroy() { 124 | if (this.uiContainer && this.uiContainer.parentNode) { 125 | this.uiContainer.parentNode.removeChild(this.uiContainer); 126 | } 127 | this.detachEventListeners(); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /web/core/js/common/components/footer.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | async function getVersion() { 3 | try { 4 | const response = await fetch('/flow/api/flow-version'); 5 | if (!response.ok) { 6 | throw new Error(`HTTP error! status: ${response.status}`); 7 | } 8 | const versionData = await response.json(); 9 | return versionData.version; 10 | } catch (error) { 11 | console.error('Error fetching version:', error); 12 | return 'Error fetching version'; 13 | } 14 | 15 | } 16 | 17 | async function insertFooter() { 18 | const version = await getVersion(); 19 | const footerHTML = ` 20 | 23 | `; 24 | let footer = document.querySelector('footer'); 25 | if (!footer) { 26 | footer = document.createElement('footer'); 27 | document.body.insertAdjacentElement('beforeend', footer); 28 | } 29 | footer.innerHTML = footerHTML; 30 | } 31 | 32 | if (document.readyState === 'loading') { 33 | document.addEventListener('DOMContentLoaded', insertFooter); 34 | } else { 35 | insertFooter(); 36 | } 37 | 38 | window.insertFooter = insertFooter; 39 | })(); 40 | -------------------------------------------------------------------------------- /web/core/js/common/components/header.js: -------------------------------------------------------------------------------- 1 | const appName = "Flow"; 2 | const headerHTML = ` 3 | 4 | 14 | 15 |

${appName}s

16 |
17 |
18 |
19 | 20 | 21 |
Support in keeping the flow.
22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 | 46 | 47 | 48 | 49 | 50 |
51 |
52 | `; 53 | 54 | export function insertElement() { 55 | const header = document.querySelector('header'); 56 | if (header) { 57 | header.innerHTML = headerHTML; 58 | } 59 | } 60 | 61 | if (document.readyState === 'loading') { 62 | document.addEventListener('DOMContentLoaded', insertElement); 63 | } else { 64 | insertElement(); 65 | } 66 | -------------------------------------------------------------------------------- /web/core/js/common/components/headerLinker.js: -------------------------------------------------------------------------------- 1 | const appName = "Flow"; 2 | const headerHTML = ` 3 | 13 |

Linker

14 |
15 |
16 |
17 | 18 | 19 |
Support in keeping the flow.
20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 |
49 |
50 | `; 51 | 52 | export function insertElement() { 53 | const header = document.querySelector('header'); 54 | if (header) { 55 | header.innerHTML = headerHTML; 56 | } 57 | } 58 | 59 | if (document.readyState === 'loading') { 60 | document.addEventListener('DOMContentLoaded', insertElement); 61 | } else { 62 | insertElement(); 63 | } 64 | -------------------------------------------------------------------------------- /web/core/js/common/components/imageLoaderComp.js: -------------------------------------------------------------------------------- 1 | import ImageLoader from './ImageLoader.js'; 2 | import {updateWorkflow} from './workflowManager.js'; 3 | 4 | let imageLoader = null; 5 | let isLoadMediaVisible = true; 6 | let destroyOnHide = false; 7 | 8 | export default function imageLoaderComp(flowConfig, workflow) { 9 | if (!flowConfig.imageLoaders || flowConfig.imageLoaders.length === 0) { 10 | // console.log('No uploaded images, skipping initialization of imageLoader.'); 11 | return; 12 | } 13 | 14 | const displayMediaMain = document.getElementById('display-media-main'); 15 | if (!displayMediaMain) { 16 | console.error('display-media-main container not found.'); 17 | return; 18 | } 19 | 20 | let loadImageContainer = document.getElementById('load-image-container'); 21 | if (!loadImageContainer) { 22 | loadImageContainer = document.createElement('div'); 23 | loadImageContainer.id = 'load-image-container'; 24 | 25 | const imageContainer = document.getElementById('image-container'); 26 | 27 | if (imageContainer && imageContainer.parentElement === displayMediaMain) { 28 | displayMediaMain.insertBefore(loadImageContainer, imageContainer); 29 | } else { 30 | displayMediaMain.appendChild(loadImageContainer); 31 | } 32 | } 33 | 34 | flowConfig.imageLoaders.forEach((imageConfig, index) => { 35 | const containerId = loadImageContainer.id; 36 | const imageDropAreaTitle = document.createElement('div'); 37 | imageDropAreaTitle.classList.add('image-loader-title'); 38 | imageDropAreaTitle.textContent = imageConfig.label; 39 | loadImageContainer.appendChild(imageDropAreaTitle); 40 | 41 | // console.log("image-loader-title",imageConfig.label); 42 | // this.imageDropAreaTitle.textContent = 'Drop an image or video here'; 43 | const imageLoader = new ImageLoader(containerId, { 44 | allowedFileType: 'image', 45 | defaultImageSrc: '/core/media/ui/drop_image_rect_no_border_trans.png', 46 | showIndicator: true, 47 | }, (localSrc, serverResult) => { 48 | console.log(`Image ${index + 1} loaded:`, serverResult); 49 | if (serverResult && serverResult.name) { 50 | console.log(flowConfig.imageLoaders[index].nodePath, imageConfig.nodePath); 51 | updateWorkflow(workflow, imageConfig.nodePath, serverResult.name); 52 | } else { 53 | console.error("Server did not return a valid result"); 54 | } 55 | }); 56 | }); 57 | } 58 | 59 | 60 | 61 | const loadMediaToggleBtn = document.getElementById('load-media-toggle-btn'); 62 | const displayMediaMain = document.getElementById('display-media-main'); 63 | 64 | function toggleLoadMedia() { 65 | isLoadMediaVisible = !isLoadMediaVisible; 66 | 67 | const loadImageContainer = document.getElementById('load-image-container'); 68 | 69 | if (isLoadMediaVisible) { 70 | if (!loadImageContainer) { 71 | const newLoadImageContainer = document.createElement('div'); 72 | newLoadImageContainer.id = 'load-image-container'; 73 | displayMediaMain.insertBefore(newLoadImageContainer, displayMediaMain.firstChild); 74 | imageLoaderComp(); 75 | } else { 76 | loadImageContainer.style.display = 'block'; 77 | } 78 | } else { 79 | if (destroyOnHide) { 80 | if (loadImageContainer) { 81 | loadImageContainer.parentNode.removeChild(loadImageContainer); 82 | } 83 | if (imageLoader) { 84 | imageLoader.destroy(); 85 | imageLoader = null; 86 | } 87 | } else { 88 | if (loadImageContainer) { 89 | loadImageContainer.style.display = 'none'; 90 | } 91 | } 92 | } 93 | } 94 | // loadMediaToggleBtn.addEventListener('click', toggleLoadMedia); 95 | 96 | function setDestroyOnHide(shouldDestroy) { 97 | destroyOnHide = shouldDestroy; 98 | } -------------------------------------------------------------------------------- /web/core/js/common/components/messageHandler.js: -------------------------------------------------------------------------------- 1 | import { WebSocketHandler } from './webSocketHandler.js'; 2 | import './progressbar.js'; 3 | import { hideSpinner } from './utils.js'; 4 | import { store } from '../scripts/stateManagerMain.js'; 5 | 6 | import { JSONMessageProcessor } from './JSONMessageProcessor.js'; 7 | import { BlobMessageProcessor } from './BlobMessageProcessor.js'; 8 | import { PreviewManager } from './previewManager.js'; 9 | 10 | export class MessageHandler { 11 | constructor() { 12 | this.lastImageFilenames = []; 13 | this.spinnerHidden = false; 14 | this.jsonProcessor = new JSONMessageProcessor(this); 15 | this.blobProcessor = new BlobMessageProcessor(this); 16 | this.previewManager = new PreviewManager(); 17 | this.progressUpdater = new window.ProgressUpdater('main-progress', 'progress-text'); 18 | } 19 | 20 | setOriginalImage(dataURL) { 21 | this.previewManager.setOriginalImage(dataURL); 22 | } 23 | 24 | setAlphaMaskImage(dataURL) { 25 | this.previewManager.setAlphaMaskImage(dataURL); 26 | } 27 | 28 | setCanvasSelectedMaskOutputs(dataURL) { 29 | this.previewManager.setCanvasSelectedMaskOutputs(dataURL); 30 | } 31 | 32 | setCroppedMaskImage(dataURL) { 33 | this.previewManager.setCroppedMaskImage(dataURL); 34 | } 35 | 36 | setMaskImage(dataURL) { 37 | this.previewManager.setMaskImage(dataURL); 38 | } 39 | 40 | handlePreviewOutputMessage(event) { 41 | if (typeof event.data === 'string') { 42 | this.jsonProcessor.process(event.data); 43 | } else if (event.data instanceof Blob) { 44 | this.blobProcessor.process(event.data).then(result => { 45 | this.previewManager.handlePreviewOutput(result); 46 | }); 47 | } else { 48 | console.warn('Unknown message type:', typeof event.data); 49 | } 50 | } 51 | 52 | handleProgress(data) { 53 | this.hideSpinnerOnce(); 54 | this.updateProgress(data); 55 | } 56 | 57 | handleStatus() { 58 | // Optional additional handling 59 | } 60 | handleMonitor(data) { 61 | // console.log('Monitor data received:', data); 62 | } 63 | hideSpinnerOnce() { 64 | if (!this.spinnerHidden) { 65 | hideSpinner(); 66 | this.spinnerHidden = true; 67 | } 68 | } 69 | 70 | handleExecuted(data) { 71 | if (data.output) { 72 | if ('images' in data.output) { 73 | this.processFinalImageOutput(data.output.images); 74 | } 75 | if ('gifs' in data.output) { 76 | this.processFinalVideoOutput(data.output.gifs); 77 | } 78 | } 79 | 80 | hideSpinner(); 81 | const event = new CustomEvent('jobCompleted'); 82 | window.dispatchEvent(event); 83 | } 84 | 85 | async processFinalImageOutput(images) { 86 | const newImageFilenames = []; 87 | const imageUrls = images.map(image => { 88 | const { filename } = image; 89 | if (filename.includes('ComfyUI_temp')) return null; 90 | if (this.lastImageFilenames.includes(filename)) return null; 91 | 92 | newImageFilenames.push(filename); 93 | const imageUrl = `/view?filename=${encodeURIComponent(filename)}`; 94 | console.log('processImages Image URL:', imageUrl); 95 | return imageUrl; 96 | }).filter(url => url !== null); 97 | 98 | if (imageUrls.length > 0) { 99 | this.previewManager.displayFinalMediaOutput(imageUrls); 100 | this.displayPreviewOutput(imageUrls); 101 | this.lastImageFilenames = newImageFilenames; 102 | return imageUrls; 103 | } 104 | } 105 | 106 | displayPreviewOutput(imageUrls) { 107 | for (const url of imageUrls) { 108 | try { 109 | const { viewType } = store.getState(); 110 | if (viewType === 'canvasView') { 111 | this.previewManager.setImageDataType('finalImageData'); 112 | this.previewManager.emitCombinedImage(url); 113 | } 114 | } catch (error) { 115 | console.error('Error overlaying preview on image:', url, error); 116 | } 117 | } 118 | } 119 | 120 | processFinalVideoOutput(videos) { 121 | const videosUrls = videos.map(video => { 122 | const { filename } = video; 123 | return `/view?filename=${encodeURIComponent(filename)}`; 124 | }); 125 | 126 | this.previewManager.displayFinalMediaOutput(videosUrls); 127 | } 128 | 129 | handleInterrupted() { 130 | hideSpinner(); 131 | console.log('Execution Interrupted'); 132 | const event = new CustomEvent('jobInterrupted'); 133 | window.dispatchEvent(event); 134 | } 135 | 136 | updateProgress(data = {}) { 137 | this.progressUpdater.update(data); 138 | } 139 | } 140 | 141 | const messageHandler = new MessageHandler(); 142 | export { messageHandler }; 143 | 144 | export function initializeWebSocket(clientId) { 145 | const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; 146 | const serverAddress = `${window.location.hostname}:${window.location.port}`; 147 | const wsHandler = new WebSocketHandler( 148 | `${protocol}://${serverAddress}/ws?clientId=${encodeURIComponent(clientId)}`, 149 | (event) => messageHandler.handlePreviewOutputMessage(event) 150 | ); 151 | wsHandler.connect(); 152 | return wsHandler; 153 | } 154 | -------------------------------------------------------------------------------- /web/core/js/common/components/mimeTypeDetector.js: -------------------------------------------------------------------------------- 1 | export async function detectMimeType(blob) { 2 | const signatureMap = { 3 | '89504E47': 'image/png', 4 | 'FFD8FF': 'image/jpeg', 5 | '47494638': 'image/gif', 6 | '424D': 'image/bmp', 7 | '52494646': 'audio/wav', 8 | '00000018': 'video/mp4', 9 | '00000020': 'video/mp4', 10 | '66747970': 'video/mp4', 11 | }; 12 | 13 | const arrayBuffer = await blob.slice(0, 8).arrayBuffer(); 14 | const uint8Array = new Uint8Array(arrayBuffer); 15 | let hex = ''; 16 | uint8Array.forEach(byte => { 17 | hex += byte.toString(16).padStart(2, '0').toUpperCase(); 18 | }); 19 | 20 | for (const signature in signatureMap) { 21 | if (hex.startsWith(signature)) { 22 | return signatureMap[signature]; 23 | } 24 | } 25 | 26 | return null; 27 | } 28 | -------------------------------------------------------------------------------- /web/core/js/common/components/progressbar.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | function formatTime(seconds) { 3 | const hrs = Math.floor(seconds / 3600); 4 | const mins = Math.floor((seconds % 3600) / 60); 5 | const secs = Math.floor(seconds % 60); 6 | return [ 7 | hrs > 0 ? String(hrs).padStart(2, '0') : '00', 8 | String(mins).padStart(2, '0'), 9 | String(secs).padStart(2, '0'), 10 | ].join(':'); 11 | } 12 | 13 | const progressBarHTML = ` 14 |
15 |
16 |
17 | 18 | 25 |
26 |
27 |
28 | 29 | 30 |
31 |
32 | `; 33 | 34 | function insertProgressBar() { 35 | let progressBar = document.querySelector('#progressbar'); 36 | if (!progressBar) { 37 | progressBar = document.createElement('div'); 38 | progressBar.id = 'progressbar'; 39 | document.body.appendChild(progressBar); 40 | } 41 | progressBar.innerHTML = progressBarHTML; 42 | injectStyles(); 43 | // console.log('Progress bar content inserted'); 44 | } 45 | 46 | function injectStyles() { 47 | const style = document.createElement('style'); 48 | style.textContent = ` 49 | 50 | `; 51 | document.head.appendChild(style); 52 | } 53 | 54 | if (document.readyState === 'loading') { 55 | document.addEventListener('DOMContentLoaded', insertProgressBar); 56 | } else { 57 | insertProgressBar(); 58 | } 59 | 60 | window.insertProgressBar = insertProgressBar; 61 | 62 | class TimeTracker { 63 | constructor() { 64 | this.startTime = null; 65 | this.stepCount = 0; 66 | this.totalSteps = 0; 67 | this.totalTime = 0; 68 | this.timePerStep = 0; 69 | } 70 | 71 | start(totalSteps) { 72 | this.startTime = Date.now(); 73 | this.stepCount = 0; 74 | this.totalSteps = totalSteps; 75 | this.totalTime = 0; 76 | this.timePerStep = 0; 77 | } 78 | 79 | update() { 80 | const now = Date.now(); 81 | const elapsed = (now - this.startTime) / 1000; 82 | this.stepCount += 1; 83 | this.timePerStep = elapsed / this.stepCount; 84 | this.totalTime = this.timePerStep * this.totalSteps; 85 | } 86 | 87 | getElapsedTime() { 88 | if (!this.startTime) return 0; 89 | return (Date.now() - this.startTime) / 1000; 90 | } 91 | 92 | getRemainingTime() { 93 | if (this.totalSteps === 0) return 0; 94 | const estimatedTotal = this.timePerStep * this.totalSteps; 95 | const remaining = estimatedTotal - this.getElapsedTime(); 96 | return Math.max(0, remaining); 97 | } 98 | 99 | getTotalTime() { 100 | return this.totalTime; 101 | } 102 | 103 | getTimePerStep() { 104 | return this.timePerStep; 105 | } 106 | } 107 | 108 | class ProgressUpdater { 109 | 110 | constructor(progressBarId, progressTextId) { 111 | this.progressBar = document.getElementById(progressBarId); 112 | this.progressText = document.getElementById(progressTextId); 113 | this.timeTracker = new TimeTracker(); 114 | this.initialized = false; 115 | 116 | if (!this.progressBar || !this.progressText) { 117 | console.warn( 118 | `Progress bar or text element not found. Setting up observer...` 119 | ); 120 | this.observeProgressElements(progressBarId, progressTextId); 121 | } else { 122 | this.initialize(); 123 | } 124 | } 125 | 126 | observeProgressElements(progressBarId, progressTextId) { 127 | const observer = new MutationObserver((mutations, obs) => { 128 | const progressBar = document.getElementById(progressBarId); 129 | const progressText = document.getElementById(progressTextId); 130 | if (progressBar && progressText) { 131 | this.progressBar = progressBar; 132 | this.progressText = progressText; 133 | this.initialize(); 134 | obs.disconnect(); 135 | } 136 | }); 137 | 138 | observer.observe(document.body, { 139 | childList: true, 140 | subtree: true, 141 | }); 142 | } 143 | 144 | 145 | initialize() { 146 | this.initialized = true; 147 | } 148 | 149 | update(data = {}) { 150 | if (!this.initialized) { 151 | console.warn('ProgressUpdater is not initialized yet.'); 152 | return; 153 | } 154 | 155 | const { max = 0, value = 0 } = data; 156 | 157 | if (value === 1) { 158 | this.timeTracker.start(max); 159 | this.showProgressText(); 160 | } 161 | 162 | if (value > 0) { 163 | this.timeTracker.update(); 164 | } 165 | 166 | this.progressBar.max = max; 167 | this.progressBar.value = value; 168 | 169 | const percentage = Math.round(max > 0 ? ((value / max) * 100).toFixed(2) : 0); 170 | const elapsedTime = formatTime(this.timeTracker.getElapsedTime()); 171 | const totalTime = formatTime(this.timeTracker.getTotalTime()); 172 | const remainingTime = formatTime(this.timeTracker.getRemainingTime()); 173 | const timePerStep = this.timeTracker.getTimePerStep().toFixed(2); 174 | 175 | const percentageSpan = this.progressText.querySelector('.progress-percentage'); 176 | const stepsSpan = this.progressText.querySelector('.progress-steps'); 177 | const timesSpan = this.progressText.querySelector('.progress-times'); 178 | 179 | if (percentageSpan) percentageSpan.textContent = `${percentage}%`; 180 | if (stepsSpan) stepsSpan.textContent = `${value}/${max}/${timePerStep}s`; 181 | if (timesSpan) timesSpan.textContent = ` ${totalTime} / ${elapsedTime} < ${remainingTime}`; 182 | 183 | // Ensure the progress text remains visible upon completion 184 | if (value >= max) { 185 | // Optionally, perform actions upon completion (e.g., notify the user) 186 | } 187 | } 188 | 189 | showProgressText() { 190 | this.progressText.classList.remove('hidden'); 191 | this.progressText.classList.add('active'); 192 | } 193 | 194 | hideProgressText() { 195 | this.progressText.classList.remove('active'); 196 | this.progressText.classList.add('hidden'); 197 | } 198 | 199 | reset() { 200 | if (!this.initialized) return; 201 | this.progressBar.value = 0; 202 | this.progressBar.max = 100; 203 | 204 | const percentageSpan = this.progressText.querySelector('.progress-percentage'); 205 | const stepsSpan = this.progressText.querySelector('.progress-steps'); 206 | const timesSpan = this.progressText.querySelector('.progress-times'); 207 | 208 | if (percentageSpan) percentageSpan.textContent = `0%`; 209 | if (stepsSpan) stepsSpan.textContent = `0/0/0.00s`; 210 | if (timesSpan) timesSpan.textContent = `00:00:00 / 00:00:00 < 00:00:00`; 211 | 212 | this.hideProgressText(); 213 | this.timeTracker = new TimeTracker(); 214 | } 215 | } 216 | 217 | window.ProgressUpdater = ProgressUpdater; 218 | })(); 219 | -------------------------------------------------------------------------------- /web/core/js/common/components/utils.js: -------------------------------------------------------------------------------- 1 | export function uuidv4() { 2 | return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => 3 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) 4 | ); 5 | } 6 | export function showSpinner() { 7 | document.getElementById('spinner').classList.add('spin'); 8 | } 9 | export function hideSpinner() { 10 | document.getElementById('spinner').classList.remove('spin'); 11 | } 12 | -------------------------------------------------------------------------------- /web/core/js/common/components/webSocketHandler.js: -------------------------------------------------------------------------------- 1 | export class WebSocketHandler { 2 | constructor(url, onMessage, onOpen, onError, onClose) { 3 | this.url = url; 4 | this.onMessage = onMessage; 5 | this.onOpen = onOpen; 6 | this.onError = onError; 7 | this.onClose = onClose; 8 | this.socket = null; 9 | } 10 | 11 | connect() { 12 | this.socket = new WebSocket(this.url); 13 | 14 | this.socket.addEventListener('open', (event) => { 15 | console.log('Connected to the server'); 16 | if (this.onOpen) this.onOpen(event); 17 | }); 18 | 19 | this.socket.addEventListener('message', (event) => { 20 | if (this.onMessage) this.onMessage(event); 21 | }); 22 | 23 | this.socket.addEventListener('error', (event) => { 24 | console.error('WebSocket error:', event); 25 | if (this.onError) this.onError(event); 26 | }); 27 | 28 | this.socket.addEventListener('close', (event) => { 29 | console.log('Disconnected from the server'); 30 | if (this.onClose) this.onClose(event); 31 | }); 32 | } 33 | 34 | send(message) { 35 | if (this.socket && this.socket.readyState === WebSocket.OPEN) { 36 | this.socket.send(JSON.stringify(message)); 37 | } else { 38 | console.error('WebSocket is not open. Unable to send message.'); 39 | } 40 | } 41 | 42 | close() { 43 | if (this.socket) { 44 | this.socket.close(); 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /web/core/js/common/components/workflowLoader.js: -------------------------------------------------------------------------------- 1 | export async function loadWorkflow(wfpath) { 2 | const response = await fetch(wfpath); 3 | if (!response.ok) { 4 | throw new Error('Failed to load workflow: ' + response.statusText); 5 | } 6 | return await response.json(); 7 | } 8 | -------------------------------------------------------------------------------- /web/core/js/common/components/workflowManager.js: -------------------------------------------------------------------------------- 1 | export function updateWorkflowValue(workflow, pathId, value, workflowConfig) { 2 | const pathConfig = workflowConfig.prompts.find(path => path.id === pathId); 3 | 4 | if (pathConfig) { 5 | const { nodePath } = pathConfig; 6 | const [a, b, c] = nodePath.split('.'); 7 | workflow[a][b][c] = value; 8 | } else { 9 | console.warn(`Workflow path not found for ID: ${pathId}`); 10 | } 11 | } 12 | 13 | export function getValueFromWorkflow(workflow, nodePath) { 14 | const pathParts = nodePath.split('.'); 15 | let value = workflow; 16 | for (const part of pathParts) { 17 | if (value.hasOwnProperty(part)) { 18 | value = value[part]; 19 | } else { 20 | return null; 21 | } 22 | } 23 | return value; 24 | } 25 | 26 | export function updateWorkflow(workflow, path, value) { 27 | const pathParts = path.split("."); 28 | let target = workflow; 29 | for (let i = 0; i < pathParts.length - 1; i++) { 30 | if (!target[pathParts[i]]) { 31 | target[pathParts[i]] = {}; 32 | } 33 | target = target[pathParts[i]]; 34 | } 35 | target[pathParts[pathParts.length - 1]] = value; 36 | } -------------------------------------------------------------------------------- /web/core/js/common/scripts/componentTypes.js: -------------------------------------------------------------------------------- 1 | export const componentTypes = { 2 | 'prompt': [ 3 | { name: 'id', label: 'ID', type: 'text' }, 4 | { name: 'label', label: 'Label', type: 'text' }, 5 | { name: 'default', label: 'Default Value', type: 'text' }, 6 | ], 7 | 'dropdown': [ 8 | { name: 'id', label: 'ID', type: 'text' }, 9 | { name: 'url', label: 'URL', type: 'text' }, 10 | { name: 'key', label: 'Key', type: 'text' }, 11 | { name: 'label', label: 'Label', type: 'text' }, 12 | ], 13 | 'stepper': [ 14 | { name: 'id', type: 'text', label: 'ID' }, 15 | { name: 'label', type: 'text', label: 'Label' }, 16 | { name: 'minValue', type: 'number', label: 'Min Value', value: 0.1 }, 17 | { name: 'maxValue', type: 'number', label: 'Max Value', value: 100.0 }, 18 | { name: 'step', type: 'number', label: 'Step', value: 0.1 }, 19 | { name: 'defValue', type: 'number', label: 'Default Value', value: 6 }, 20 | { name: 'precision', type: 'number', label: 'Precision', value: 1 }, 21 | { name: 'scaleFactor', type: 'number', label: 'Scale Factor', value: 10 }, 22 | ], 23 | 'input': [ 24 | { name: 'id', label: 'ID', type: 'text' }, 25 | { name: 'label', label: 'Label', type: 'text' }, 26 | { name: 'defValue', label: 'Default Value', type: 'text' }, 27 | ], 28 | 'toggle': [ 29 | { name: 'id', label: 'ID', type: 'text' }, 30 | { name: 'label', label: 'Label', type: 'text' }, 31 | { name: 'defaultValue', label: 'Default Value' , value: true}, 32 | ], 33 | 'seeder': [ 34 | { name: 'id', label: 'ID', type: 'text' }, 35 | { name: 'label', label: 'Label', type: 'text' }, 36 | ], 37 | 'multiComponent': [ 38 | { name: 'id', label: 'ID', type: 'text' }, 39 | { name: 'label', label: 'Label', type: 'text' }, 40 | ], 41 | 'dimensionSelector': [ 42 | { name: 'id', label: 'ID', type: 'text' }, 43 | { name: 'defaultWidth', label: 'Default Width', type: 'number' }, 44 | { name: 'defaultHeight', label: 'Default Height', type: 'number' }, 45 | ], 46 | 'imageLoader': [ 47 | { name: 'id', label: 'ID', type: 'text' }, 48 | { name: 'label', label: 'Label', type: 'text' }, 49 | ], 50 | 'canvasOutput': [ 51 | { name: 'id', label: 'ID', type: 'text' }, 52 | { name: 'label', label: 'Label', type: 'text' }, 53 | ], 54 | 'canvasLoadedImage': [ 55 | { name: 'id', label: 'ID', type: 'text' }, 56 | { name: 'label', label: 'Label', type: 'text' }, 57 | ], 58 | 'canvasAlphaOutput': [ 59 | { name: 'id', label: 'ID', type: 'text' }, 60 | { name: 'label', label: 'Label', type: 'text' }, 61 | ], 62 | 'canvasSelectedMaskOutput': [ 63 | { name: 'id', label: 'ID', type: 'text' }, 64 | { name: 'label', label: 'Label', type: 'text' }, 65 | ], 66 | 'canvasCroppedMaskOutput': [ 67 | { name: 'id', label: 'ID', type: 'text' }, 68 | { name: 'label', label: 'Label', type: 'text' }, 69 | ], 70 | 'canvasCroppedImageOutput': [ 71 | { name: 'id', label: 'ID', type: 'text' }, 72 | { name: 'label', label: 'Label', type: 'text' }, 73 | ], 74 | 'canvasCroppedAlphaOnImageOutput': [ 75 | { name: 'id', label: 'ID', type: 'text' }, 76 | { name: 'label', label: 'Label', type: 'text' }, 77 | ], 78 | 79 | 'dataComponent': [ 80 | { name: 'id', label: 'ID', type: 'text' }, 81 | { name: 'name', label: 'NAME', type: 'text' }, 82 | { name: 'dataPath', label: 'Data Path', type: 'text'}, 83 | ], 84 | }; -------------------------------------------------------------------------------- /web/core/js/common/scripts/corePath.js: -------------------------------------------------------------------------------- 1 | export const coreScriptsPath = [ 2 | '/core/js/common/components/header.js', 3 | '/core/js/common/components/footer.js', 4 | '/core/js/common/components/progressbar.js', 5 | '/core/js/common/scripts/init.js' 6 | ]; -------------------------------------------------------------------------------- /web/core/js/common/scripts/corePathLinker.js: -------------------------------------------------------------------------------- 1 | export const coreScriptsPath = [ 2 | '/core/js/common/components/headerLinker.js', 3 | '/core/js/common/components/footer.js', 4 | '/core/js/common/components/progressbar.js', 5 | '/core/js/common/scripts/init.js' 6 | ]; -------------------------------------------------------------------------------- /web/core/js/common/scripts/favicon.js: -------------------------------------------------------------------------------- 1 | const FAVICON_BASE_PATH = '/core/media/ui/'; 2 | 3 | const FAVICONS = { 4 | DEFAULT: 'flow_logo.png', 5 | RUNNING: 'g_flow_logo.png', 6 | ERROR: 'r_flow_logo.png' 7 | }; 8 | 9 | export const setFaviconStatus = { 10 | Default() { 11 | updateFavicon(FAVICONS.DEFAULT); 12 | }, 13 | Running() { 14 | updateFavicon(FAVICONS.RUNNING); 15 | }, 16 | Error() { 17 | updateFavicon(FAVICONS.ERROR); 18 | } 19 | }; 20 | 21 | function updateFavicon(iconUrl) { 22 | const link = document.querySelector('link[rel="icon"]') || createFaviconLink(); 23 | link.href = `${FAVICON_BASE_PATH}${iconUrl}`; 24 | } 25 | 26 | function createFaviconLink() { 27 | const link = document.createElement('link'); 28 | link.rel = 'icon'; 29 | link.type = 'image/png'; 30 | document.head.appendChild(link); 31 | return link; 32 | } 33 | 34 | setFaviconStatus.Default(); 35 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/fetchWorkflow.js: -------------------------------------------------------------------------------- 1 | import { loadWorkflow } from './workflowLoader.js'; 2 | 3 | export async function fetchWorkflow(flowName) { 4 | try { 5 | let flow = flowName; 6 | if (!flow) { 7 | const paths = window.location.pathname.split('/').filter(Boolean); 8 | if (paths[0] === 'flow' && paths[1]) { 9 | flow = paths[1]; 10 | } else { 11 | throw new Error('Invalid path: Expected /flow/{name}'); 12 | } 13 | } 14 | const cacheBuster = `?cacheFlow=${Date.now()}`; 15 | const wfpath_url = `/flow/${encodeURIComponent(flow)}/wf.json${cacheBuster}`; 16 | const workflow = await loadWorkflow(wfpath_url); 17 | return workflow; 18 | } catch (error) { 19 | console.error('Failed to load wf.json:', error); 20 | throw error; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/fetchflowConfig.js: -------------------------------------------------------------------------------- 1 | export async function fetchflowConfig(flowName) { 2 | try { 3 | let flow = flowName; 4 | if (!flow) { 5 | const paths = window.location.pathname.split('/').filter(Boolean); 6 | if (paths[0] === 'flow' && paths[1]) { 7 | flow = paths[1]; 8 | } else { 9 | throw new Error('Invalid path: Expected /flow/{name}'); 10 | } 11 | } 12 | const cacheBuster = `?cacheFlow=${Date.now()}`; 13 | const jsonPath = `/flow/${encodeURIComponent(flow)}/flowConfig.json${cacheBuster}`; 14 | const response = await fetch(jsonPath); 15 | if (!response.ok) { 16 | throw new Error(`Failed to fetch flowConfig.json for flow '${flow}'. HTTP status: ${response.status}`); 17 | } 18 | const flowConfig = await response.json(); 19 | return flowConfig; 20 | } catch (error) { 21 | console.error('Failed to load flowConfig.json:', error); 22 | throw error; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/init.js: -------------------------------------------------------------------------------- 1 | export class ConfigurationLoader { 2 | constructor() { 3 | this.basePath = window.location.pathname.split('/flow/')[1]; 4 | this.configPath = `/flow/${this.basePath}/flowConfig.json`; 5 | } 6 | async load() { 7 | try { 8 | const response = await fetch(this.configPath); 9 | if (!response.ok) { 10 | throw new Error(`Failed to load config from ${this.configPath}: ${response.statusText}`); 11 | } 12 | return await response.json(); 13 | } catch (error) { 14 | console.error('Error loading configuration:', error); 15 | throw error; 16 | } 17 | } 18 | } 19 | 20 | export class UIUpdater { 21 | static updateTitle(title) { 22 | document.title = title; 23 | } 24 | 25 | static updateHeader(headerText) { 26 | const headerElement = document.querySelector('header h2'); 27 | if (headerElement) { 28 | headerElement.textContent = headerText; 29 | } 30 | } 31 | } 32 | 33 | (async function initApp() { 34 | try { 35 | const configLoader = new ConfigurationLoader(); 36 | const config = await configLoader.load(); 37 | UIUpdater.updateTitle(config.name); 38 | UIUpdater.updateHeader(config.name); 39 | } catch (error) { 40 | console.error('Initialization failed:', error); 41 | } 42 | })(); 43 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/injectStylesheet.js: -------------------------------------------------------------------------------- 1 | function injectStylesheet(href, id = null) { 2 | if (id && document.getElementById(id)) { 3 | console.warn(`Stylesheet with id "${id}" is already injected.`); 4 | return; 5 | } 6 | 7 | const link = document.createElement('link'); 8 | link.rel = 'preload'; 9 | link.as = 'style'; 10 | link.href = href; 11 | 12 | if (id) { 13 | link.id = id; 14 | } 15 | 16 | link.onload = function() { 17 | this.onload = null; 18 | this.rel = 'stylesheet'; 19 | // console.log(`Stylesheet "${href}" has been loaded and applied.`); 20 | }; 21 | 22 | link.onerror = function() { 23 | console.error(`Failed to load stylesheet "${href}".`); 24 | }; 25 | 26 | document.head.appendChild(link); 27 | } 28 | 29 | export default injectStylesheet; 30 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/nodesscanner.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = `${window.location.origin}/object_info/`; 2 | 3 | async function fetchNodeInfo(classType) { 4 | try { 5 | const response = await fetch(`${BASE_URL}${encodeURIComponent(classType)}`); 6 | if (!response.ok) { 7 | throw new Error(`HTTP error! status: ${response.status}`); 8 | } 9 | const data = await response.json(); 10 | return data[classType] || null; 11 | } catch (error) { 12 | console.error(`Error fetching data for ${classType}:`, error); 13 | return null; 14 | } 15 | } 16 | 17 | async function fetchExtensionNodeMap() { 18 | try { 19 | const response = await fetch('/flow/api/extension-node-map'); 20 | if (!response.ok) { 21 | throw new Error(`HTTP error! status: ${response.status}`); 22 | } 23 | const data = await response.json(); 24 | return data; 25 | } catch (error) { 26 | console.error('Error fetching extension node map:', error); 27 | return {}; 28 | } 29 | } 30 | 31 | async function processWorkflowNodes(workflow, filterCustomNodes = false) { 32 | const nodeToCustomNodeMap = {}; 33 | const uniqueCustomNodesSet = new Set(); 34 | const nodeTypeCount = {}; 35 | const missingNodesSet = new Set(); 36 | 37 | const extensionNodeMap = await fetchExtensionNodeMap(); 38 | 39 | const processNode = async (nodeId, node) => { 40 | const classType = node.class_type; 41 | if (classType) { 42 | nodeTypeCount[classType] = (nodeTypeCount[classType] || 0) + 1; 43 | const nodeInfo = await fetchNodeInfo(classType); 44 | if (nodeInfo && nodeInfo.python_module) { 45 | if (!filterCustomNodes || nodeInfo.python_module.includes('custom_nodes')) { 46 | const inputPaths = {}; 47 | for (const [inputName, inputValue] of Object.entries(node.inputs)) { 48 | inputPaths[inputName] = `${nodeId}.inputs.${inputName}`; 49 | } 50 | nodeToCustomNodeMap[nodeId] = { 51 | pythonModule: nodeInfo.python_module, 52 | classType, 53 | count: nodeTypeCount[classType], 54 | inputPaths, 55 | inputs: nodeInfo.input, 56 | outputs: nodeInfo.output 57 | }; 58 | uniqueCustomNodesSet.add(nodeInfo.python_module.split('.').pop()); 59 | } 60 | } else { 61 | missingNodesSet.add(classType); 62 | } 63 | } 64 | }; 65 | 66 | const nodeProcessingPromises = Object.entries(workflow).map(([nodeId, node]) => processNode(nodeId, node)); 67 | await Promise.all(nodeProcessingPromises); 68 | 69 | const missingNodes = Array.from(missingNodesSet); 70 | const missingCustomPackagesMap = new Map(); 71 | 72 | missingNodes.forEach(missingNode => { 73 | for (const [packageUrl, packageInfo] of Object.entries(extensionNodeMap)) { 74 | const [nodeClasses, extraInfo] = packageInfo; 75 | const title = extraInfo.title_aux; 76 | 77 | if (nodeClasses.includes(missingNode)) { 78 | if (!missingCustomPackagesMap.has(packageUrl)) { 79 | missingCustomPackagesMap.set(packageUrl, { title: title, packageUrl: packageUrl }); 80 | } 81 | } 82 | } 83 | }); 84 | 85 | const missingCustomPackages = Array.from(missingCustomPackagesMap.values()); 86 | 87 | return { 88 | nodeToCustomNodeMap, 89 | uniqueCustomNodesArray: Array.from(uniqueCustomNodesSet), 90 | nodeTypeCount, 91 | missingNodes, 92 | missingCustomPackages 93 | }; 94 | } 95 | 96 | async function analyzeWorkflow(workflow) { 97 | const { nodeToCustomNodeMap, uniqueCustomNodesArray, nodeTypeCount, missingNodes, missingCustomPackages } = await processWorkflowNodes(workflow); 98 | const nodeAnalysis = Object.entries(workflow).map(([nodeId, node]) => ({ 99 | id: nodeId, 100 | type: node.class_type, 101 | inputs: Object.keys(node.inputs).length, 102 | customNode: nodeToCustomNodeMap[nodeId] ? 'Yes' : 'No', 103 | title: node._meta?.title || 'Untitled', 104 | inputPaths: nodeToCustomNodeMap[nodeId]?.inputPaths || {} 105 | })); 106 | return { 107 | totalNodes: Object.keys(workflow).length, 108 | uniqueNodeTypes: Object.keys(nodeTypeCount).length, 109 | nodeTypeFrequency: nodeTypeCount, 110 | customNodes: uniqueCustomNodesArray, 111 | missingNodes, 112 | missingCustomPackages, 113 | nodeAnalysis, 114 | nodeToCustomNodeMap 115 | }; 116 | } 117 | 118 | export { fetchNodeInfo, fetchExtensionNodeMap, processWorkflowNodes, analyzeWorkflow }; 119 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/preferences.js: -------------------------------------------------------------------------------- 1 | const PREFS_KEY = 'FlowMenuPref'; 2 | 3 | class PreferencesManager { 4 | constructor(defaultPrefs) { 5 | this.preferences = { ...defaultPrefs }; 6 | this.loadPreferences(); 7 | } 8 | 9 | loadPreferences() { 10 | const storedPrefs = localStorage.getItem(PREFS_KEY); 11 | if (storedPrefs) { 12 | try { 13 | const parsedPrefs = JSON.parse(storedPrefs); 14 | this.preferences = { ...this.preferences, ...parsedPrefs }; 15 | } catch (e) { 16 | console.error('Error parsing preferences from localStorage:', e); 17 | } 18 | } 19 | } 20 | 21 | savePreferences() { 22 | try { 23 | localStorage.setItem(PREFS_KEY, JSON.stringify(this.preferences)); 24 | } catch (e) { 25 | console.error('Error saving preferences to localStorage:', e); 26 | } 27 | } 28 | 29 | get(prefKey) { 30 | return this.preferences[prefKey]; 31 | } 32 | 33 | set(prefKey, value) { 34 | this.preferences[prefKey] = value; 35 | this.savePreferences(); 36 | } 37 | 38 | addPreference(prefKey, defaultValue) { 39 | if (!(prefKey in this.preferences)) { 40 | this.preferences[prefKey] = defaultValue; 41 | this.savePreferences(); 42 | } 43 | } 44 | } 45 | 46 | export { PreferencesManager, }; 47 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/state.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | jobQueue: [], 3 | currentJobId: 0, 4 | isProcessing: false, 5 | }; 6 | 7 | export default state; 8 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/stateManager.js: -------------------------------------------------------------------------------- 1 | import state from './state.js'; 2 | 3 | class StateManager { 4 | static addJob(job) { 5 | state.jobQueue.push(job); 6 | } 7 | 8 | static getNextJob() { 9 | return state.jobQueue[0]; 10 | } 11 | 12 | static removeJob() { 13 | state.jobQueue.shift(); 14 | } 15 | 16 | static incrementJobId() { 17 | state.currentJobId += 1; 18 | return state.currentJobId; 19 | } 20 | 21 | static setProcessing(value) { 22 | state.isProcessing = value; 23 | } 24 | 25 | static isProcessing() { 26 | return state.isProcessing; 27 | } 28 | 29 | static getJobQueue() { 30 | return state.jobQueue; 31 | } 32 | 33 | static getCurrentJobId() { 34 | return state.currentJobId; 35 | } 36 | } 37 | 38 | export default StateManager; 39 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/stateManagerMain.js: -------------------------------------------------------------------------------- 1 | class StateManager { 2 | constructor(initialState = {}) { 3 | // console.log('StateManager initializing with:', initialState); 4 | this.state = initialState; 5 | this.listeners = new Set(); 6 | this.history = []; 7 | this.historyLimit = 100; 8 | 9 | this.dispatch = this.dispatch.bind(this); 10 | this.subscribe = this.subscribe.bind(this); 11 | this.getState = this.getState.bind(this); 12 | 13 | this.state = new Proxy(this.state, { 14 | set: () => { 15 | throw new Error('Direct state mutation is not allowed. Use dispatch instead.'); 16 | } 17 | }); 18 | } 19 | 20 | getState() { 21 | // console.log('Getting current state:', this.state); 22 | return Object.freeze({ ...this.state }); 23 | } 24 | 25 | subscribe(listener) { 26 | // console.log('New subscriber added to StateManager'); 27 | if (typeof listener !== 'function') { 28 | throw new TypeError('Listener must be a function'); 29 | } 30 | this.listeners.add(listener); 31 | 32 | listener(this.getState()); 33 | 34 | return () => { 35 | // console.log('Subscriber removed from StateManager'); 36 | this.listeners.delete(listener); 37 | }; 38 | } 39 | 40 | dispatch(action) { 41 | // console.log('Action dispatched:', action); 42 | if (!action || typeof action !== 'object' || !action.type) { 43 | throw new TypeError('Action must be an object with a type property'); 44 | } 45 | 46 | try { 47 | const nextState = this.reducer(this.state, action); 48 | 49 | if (nextState === undefined || nextState === null) { 50 | throw new Error('Reducer must return a valid state object'); 51 | } 52 | 53 | // console.log('Previous state:', this.state); 54 | // console.log('Next state:', nextState); 55 | 56 | this.state = nextState; 57 | 58 | this.saveToHistory(action, nextState); 59 | 60 | this.notifyListeners(); 61 | 62 | return true; 63 | } catch (error) { 64 | console.error('Error dispatching action:', error); 65 | return false; 66 | } 67 | } 68 | 69 | reducer(state, action) { 70 | switch (action.type) { 71 | case 'SET_VIEW': 72 | return { ...state, viewType: action.payload }; 73 | case 'TOGGLE_MASK': 74 | return { ...state, hideMask: !state.hideMask }; 75 | case 'SET_HIDE_MASK': 76 | return { ...state, hideMask: action.payload }; 77 | case 'SET_CROPPED_IMAGE': 78 | return { ...state, croppedImage: action.payload }; 79 | case 'SET_MASKING_TYPE': 80 | return { ...state, maskingType: action.payload }; 81 | case 'SET_QUEUE_RUNNING': 82 | return { ...state, isQueueRunning: action.payload }; 83 | case 'RESET_STATE': 84 | return this.constructor.initialState; 85 | default: 86 | return state; 87 | } 88 | } 89 | 90 | notifyListeners() { 91 | // console.log('Notifying', this.listeners.size, 'listeners'); 92 | this.listeners.forEach(listener => { 93 | try { 94 | listener(this.getState()); 95 | } catch (error) { 96 | console.error('Error in listener:', error); 97 | } 98 | }); 99 | } 100 | 101 | saveToHistory(action, state) { 102 | const timestamp = new Date().toISOString(); 103 | this.history.push({ 104 | action, 105 | state: { ...state }, 106 | timestamp 107 | }); 108 | 109 | if (this.history.length > this.historyLimit) { 110 | this.history.shift(); 111 | } 112 | // console.log('History updated, current length:', this.history.length); 113 | } 114 | } 115 | 116 | export const store = new StateManager({ 117 | viewType: 'standardView', // Possible values: 'standardView', 'canvasView', 'splitView' 118 | hideMask:false, 119 | croppedImage: {}, 120 | maskingType: 'full', // Possible values: 'full', 'cropped' 121 | isQueueRunning: false, 122 | }); 123 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/ui_utils.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | const extension = { 3 | name: "flow.widget", 4 | }; 5 | 6 | app.registerExtension(extension); 7 | const config = { 8 | newTab: true, 9 | }; 10 | 11 | const createWidget = ({ className, text, tooltip, includeIcon, svgMarkup }) => { 12 | const button = document.createElement('button'); 13 | button.className = className; 14 | button.setAttribute('aria-label', tooltip); 15 | button.title = tooltip; 16 | 17 | if (includeIcon && svgMarkup) { 18 | const iconContainer = document.createElement('span'); 19 | iconContainer.innerHTML = svgMarkup; 20 | iconContainer.style.display = 'flex'; 21 | iconContainer.style.alignItems = 'center'; 22 | iconContainer.style.justifyContent = 'center'; 23 | iconContainer.style.width = '40px'; 24 | iconContainer.style.height = '16px'; 25 | button.appendChild(iconContainer); 26 | } 27 | 28 | const textNode = document.createTextNode(text); 29 | button.appendChild(textNode); 30 | 31 | button.addEventListener('click', onClick); 32 | return button; 33 | }; 34 | 35 | const onClick = () => { 36 | const flowUrl = `${window.location.origin}/flow`; 37 | if (config.newTab) { 38 | window.open(flowUrl, '_blank'); 39 | } else { 40 | window.location.href = flowUrl; 41 | } 42 | }; 43 | 44 | const addWidgetMenuRight = (menuRight) => { 45 | let buttonGroup = menuRight.querySelector('.comfyui-button-group'); 46 | 47 | if (!buttonGroup) { 48 | buttonGroup = document.createElement('div'); 49 | buttonGroup.className = 'comfyui-button-group'; 50 | menuRight.appendChild(buttonGroup); 51 | } 52 | 53 | const flowButton = createWidget({ 54 | className: 'comfyui-button comfyui-menu-mobile-collapse primary', 55 | text: '', 56 | tooltip: 'Launch Flow', 57 | includeIcon: true, 58 | svgMarkup: getFlowIcon(), 59 | }); 60 | 61 | buttonGroup.appendChild(flowButton); 62 | }; 63 | 64 | const addWidgetMenu = (menu) => { 65 | const resetViewButton = menu.querySelector('#comfy-reset-view-button'); 66 | if (!resetViewButton) { 67 | return; 68 | } 69 | 70 | const flowButton = createWidget({ 71 | className: 'comfy-flow-button', 72 | text: 'Flow', 73 | tooltip: 'Launch Flow', 74 | includeIcon: false, 75 | }); 76 | 77 | resetViewButton.insertAdjacentElement('afterend', flowButton); 78 | }; 79 | 80 | const addWidget = (selector, callback) => { 81 | const observer = new MutationObserver((mutations, obs) => { 82 | const element = document.querySelector(selector); 83 | if (element) { 84 | callback(element); 85 | obs.disconnect(); 86 | } 87 | }); 88 | 89 | observer.observe(document.body, { childList: true, subtree: true }); 90 | }; 91 | 92 | const initializeWidgets = () => { 93 | addWidget('.comfyui-menu-right', addWidgetMenuRight); 94 | addWidget('.comfy-menu', addWidgetMenu); 95 | }; 96 | 97 | const getFlowIcon = () => { 98 | return ` 99 | 102 | 104 | 140 | 144 | 145 | 146 | `; 147 | }; 148 | 149 | initializeWidgets(); 150 | -------------------------------------------------------------------------------- /web/core/js/common/scripts/workflowLoader.js: -------------------------------------------------------------------------------- 1 | export async function loadWorkflow(wfpath) { 2 | const response = await fetch(wfpath); 3 | if (!response.ok) { 4 | throw new Error('Failed to load workflow: ' + response.statusText); 5 | } 6 | return await response.json(); 7 | } 8 | -------------------------------------------------------------------------------- /web/core/loadScripts.js: -------------------------------------------------------------------------------- 1 | import { coreScriptsPath } from '/core/js/common/scripts/corePath.js'; 2 | 3 | const urlParams = new URLSearchParams(window.location.search); 4 | const flowName = urlParams.get('flow'); 5 | 6 | const isLinker = flowName !== undefined && flowName !== null; 7 | 8 | const config = { 9 | scripts: ['/core/main.js'], 10 | coreScripts: isLinker 11 | ? coreScriptsPath.filter(src => !src.includes('init.js')) 12 | : coreScriptsPath, 13 | flowName: flowName 14 | }; 15 | 16 | const loadScript = (src) => { 17 | return new Promise((resolve, reject) => { 18 | const script = document.createElement('script'); 19 | script.type = 'module'; 20 | if (src.includes('main.js')) { 21 | script.src = `${src}${config.flowName ? '?flow=' + encodeURIComponent(config.flowName) : ''}`; 22 | } else { 23 | script.src = src; 24 | } 25 | script.onload = resolve; 26 | script.onerror = reject; 27 | document.head.appendChild(script); 28 | // console.log(`${src} loaded`); 29 | }); 30 | }; 31 | 32 | const loadCoreScripts = async () => { 33 | for (const src of config.coreScripts) { 34 | await loadScript(src); 35 | } 36 | }; 37 | 38 | const loadAppScripts = async () => { 39 | for (const src of config.scripts) { 40 | await loadScript(src); 41 | } 42 | }; 43 | 44 | const init = async () => { 45 | try { 46 | await loadCoreScripts(); 47 | await loadAppScripts(); 48 | console.log('All scripts loaded successfully'); 49 | } catch (error) { 50 | console.error('Error loading scripts:', error); 51 | } 52 | }; 53 | 54 | init(); 55 | -------------------------------------------------------------------------------- /web/core/loadScriptsLinker.js: -------------------------------------------------------------------------------- 1 | import { coreScriptsPath } from '/core/js/common/scripts/corePathLinker.js'; 2 | 3 | const urlParams = new URLSearchParams(window.location.search); 4 | const flowName = urlParams.get('flow'); 5 | 6 | const isLinker = flowName !== undefined && flowName !== null; 7 | 8 | const config = { 9 | scripts: ['/core/main.js'], 10 | coreScripts: isLinker 11 | ? coreScriptsPath.filter(src => !src.includes('init.js')) 12 | : coreScriptsPath, 13 | flowName: flowName 14 | }; 15 | 16 | const loadScript = (src) => { 17 | return new Promise((resolve, reject) => { 18 | const script = document.createElement('script'); 19 | script.type = 'module'; 20 | if (src.includes('main.js')) { 21 | script.src = `${src}${config.flowName ? '?flow=' + encodeURIComponent(config.flowName) : ''}`; 22 | } else { 23 | script.src = src; 24 | } 25 | script.onload = resolve; 26 | script.onerror = reject; 27 | document.head.appendChild(script); 28 | console.log(`${src} loaded`); 29 | }); 30 | }; 31 | 32 | const loadCoreScripts = async () => { 33 | for (const src of config.coreScripts) { 34 | await loadScript(src); 35 | } 36 | }; 37 | 38 | const loadAppScripts = async () => { 39 | for (const src of config.scripts) { 40 | await loadScript(src); 41 | } 42 | }; 43 | 44 | const init = async () => { 45 | try { 46 | await loadCoreScripts(); 47 | await loadAppScripts(); 48 | console.log('All scripts loaded successfully'); 49 | } catch (error) { 50 | console.error('Error loading scripts:', error); 51 | } 52 | }; 53 | 54 | init(); 55 | -------------------------------------------------------------------------------- /web/core/media/git/cover_flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/git/cover_flow.jpg -------------------------------------------------------------------------------- /web/core/media/git/flow_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/git/flow_1.jpg -------------------------------------------------------------------------------- /web/core/media/git/flow_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/git/flow_2.jpg -------------------------------------------------------------------------------- /web/core/media/git/flow_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/git/flow_3.jpg -------------------------------------------------------------------------------- /web/core/media/git/flow_4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/git/flow_4.jpg -------------------------------------------------------------------------------- /web/core/media/git/flow_5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/git/flow_5.jpg -------------------------------------------------------------------------------- /web/core/media/git/flow_yt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/git/flow_yt.jpg -------------------------------------------------------------------------------- /web/core/media/git/patreon.svg: -------------------------------------------------------------------------------- 1 | 2 | patreon 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 19 | 20 | -------------------------------------------------------------------------------- /web/core/media/ui/area_in_zoom_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/area_in_zoom_icon.png -------------------------------------------------------------------------------- /web/core/media/ui/area_out_zoom_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/area_out_zoom_icon.png -------------------------------------------------------------------------------- /web/core/media/ui/area_zoom_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/area_zoom_icon.png -------------------------------------------------------------------------------- /web/core/media/ui/dice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/dice.png -------------------------------------------------------------------------------- /web/core/media/ui/double-face-mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/double-face-mask.png -------------------------------------------------------------------------------- /web/core/media/ui/drop_image_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/drop_image_main.png -------------------------------------------------------------------------------- /web/core/media/ui/drop_image_main2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/drop_image_main2.png -------------------------------------------------------------------------------- /web/core/media/ui/drop_image_rect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/drop_image_rect.png -------------------------------------------------------------------------------- /web/core/media/ui/drop_image_rect_no_border.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/drop_image_rect_no_border.png -------------------------------------------------------------------------------- /web/core/media/ui/drop_image_rect_no_border_trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/drop_image_rect_no_border_trans.png -------------------------------------------------------------------------------- /web/core/media/ui/flow_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/flow_logo.png -------------------------------------------------------------------------------- /web/core/media/ui/g_flow_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/g_flow_logo.png -------------------------------------------------------------------------------- /web/core/media/ui/i2i.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/i2i.png -------------------------------------------------------------------------------- /web/core/media/ui/minus_icon_n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/minus_icon_n.png -------------------------------------------------------------------------------- /web/core/media/ui/paintree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/paintree.png -------------------------------------------------------------------------------- /web/core/media/ui/pen_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/pen_icon.png -------------------------------------------------------------------------------- /web/core/media/ui/plus_icon_n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/plus_icon_n.png -------------------------------------------------------------------------------- /web/core/media/ui/r_flow_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/r_flow_logo.png -------------------------------------------------------------------------------- /web/core/media/ui/top-view_30trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/top-view_30trans.png -------------------------------------------------------------------------------- /web/core/media/ui/update_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/core/media/ui/update_logo.png -------------------------------------------------------------------------------- /web/core/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 30 | 31 | -------------------------------------------------------------------------------- /web/flow/conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "menu", 3 | "name": "menu", 4 | "url": "flow", 5 | "description": "Core functionality and base operations" 6 | } 7 | -------------------------------------------------------------------------------- /web/linker/app.js: -------------------------------------------------------------------------------- 1 | import { initializeSaveOptions, updateWorkflowConfig } from './configHandler.js'; 2 | import { initializeFileHandlers } from './fileHandler.js'; 3 | import { displayNodes, displayNodeInfo } from './nodeHandler.js'; 4 | import { initializeMultiComponentHandler } from './multiComponentHandler.js'; 5 | 6 | const state = { 7 | nodeToCustomNodeMap: {}, 8 | assignedComponents: [], 9 | multiComponents: [], 10 | components: [], 11 | flowId: '', 12 | flowName: '', 13 | flowUrl: '', 14 | componentCounters: {}, 15 | flowDescription: '', 16 | thumbnail: null, 17 | }; 18 | 19 | function generateFlowId() { 20 | return Math.random().toString(36).substring(2, 7); 21 | } 22 | 23 | function toKebabCase(str) { 24 | return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, ''); 25 | } 26 | 27 | function generateFlowUrl(flowId, flowName) { 28 | return `${flowId}-${toKebabCase(flowName)}`; 29 | } 30 | 31 | function initializeApp() { 32 | const nodeDropdown = document.getElementById('nodeDropdown'); 33 | const nodeInfoElement = document.getElementById('nodeInfo'); 34 | const flowNameInput = document.getElementById('flowName'); 35 | const flowDescriptionInput = document.getElementById('flowDescription'); 36 | 37 | if (!nodeDropdown || !nodeInfoElement || !flowNameInput || !flowDescriptionInput) return; 38 | 39 | initializeSaveOptions(state); 40 | 41 | flowNameInput.addEventListener('input', () => { 42 | state.flowName = flowNameInput.value.trim(); 43 | if (!state.flowName) { 44 | state.flowId = ''; 45 | state.flowUrl = ''; 46 | } else { 47 | if (!state.flowId) state.flowId = generateFlowId(); 48 | state.flowUrl = generateFlowUrl(state.flowId, state.flowName); 49 | } 50 | updateWorkflowConfig(state); 51 | }); 52 | 53 | flowDescriptionInput.addEventListener('input', () => { 54 | state.flowDescription = flowDescriptionInput.value.trim(); 55 | updateWorkflowConfig(state); 56 | }); 57 | 58 | initializeFileHandlers(state, displayNodes); 59 | 60 | nodeDropdown.addEventListener('change', function () { 61 | const nodeId = this.value; 62 | if (nodeId) { 63 | const nodeInfo = state.nodeToCustomNodeMap[nodeId]; 64 | displayNodeInfo(nodeId, nodeInfo, state); 65 | } else { 66 | nodeInfoElement.innerHTML = ''; 67 | } 68 | }); 69 | 70 | initializeMultiComponentHandler(state); 71 | } 72 | 73 | document.addEventListener('DOMContentLoaded', initializeApp); 74 | export { state }; 75 | -------------------------------------------------------------------------------- /web/linker/defFlowConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "linker", 3 | "name": "Flow linker", 4 | "url": "linker", 5 | "description": "Link your workflows." 6 | } -------------------------------------------------------------------------------- /web/linker/defwf.json: -------------------------------------------------------------------------------- 1 | { 2 | "3": { 3 | "inputs": { 4 | "seed": 44460315132590, 5 | "steps": 20, 6 | "cfg": 8, 7 | "sampler_name": "euler", 8 | "scheduler": "normal", 9 | "denoise": 1, 10 | "model": [ 11 | "4", 12 | 0 13 | ], 14 | "positive": [ 15 | "6", 16 | 0 17 | ], 18 | "negative": [ 19 | "7", 20 | 0 21 | ], 22 | "latent_image": [ 23 | "5", 24 | 0 25 | ] 26 | }, 27 | "class_type": "KSampler", 28 | "_meta": { 29 | "title": "KSampler" 30 | } 31 | }, 32 | "4": { 33 | "inputs": { 34 | "ckpt_name": "tamarinXL_v10.safetensors" 35 | }, 36 | "class_type": "CheckpointLoaderSimple", 37 | "_meta": { 38 | "title": "Load Checkpoint" 39 | } 40 | }, 41 | "5": { 42 | "inputs": { 43 | "width": 512, 44 | "height": 512, 45 | "batch_size": 1 46 | }, 47 | "class_type": "EmptyLatentImage", 48 | "_meta": { 49 | "title": "Empty Latent Image" 50 | } 51 | }, 52 | "6": { 53 | "inputs": { 54 | "text": "A cartoon happy goat with purple eyes and a black horn in the jungle", 55 | "clip": [ 56 | "4", 57 | 1 58 | ] 59 | }, 60 | "class_type": "CLIPTextEncode", 61 | "_meta": { 62 | "title": "CLIP Text Encode (Prompt)" 63 | } 64 | }, 65 | "7": { 66 | "inputs": { 67 | "text": "text, watermark", 68 | "clip": [ 69 | "4", 70 | 1 71 | ] 72 | }, 73 | "class_type": "CLIPTextEncode", 74 | "_meta": { 75 | "title": "CLIP Text Encode (Prompt)" 76 | } 77 | }, 78 | "8": { 79 | "inputs": { 80 | "samples": [ 81 | "3", 82 | 0 83 | ], 84 | "vae": [ 85 | "4", 86 | 2 87 | ] 88 | }, 89 | "class_type": "VAEDecode", 90 | "_meta": { 91 | "title": "VAE Decode" 92 | } 93 | }, 94 | "9": { 95 | "inputs": { 96 | "filename_prefix": "Flow", 97 | "images": [ 98 | "8", 99 | 0 100 | ] 101 | }, 102 | "class_type": "SaveImage", 103 | "_meta": { 104 | "title": "Save Image" 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /web/linker/flow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow Linker 6 | 7 | 8 | 9 |
10 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /web/linker/flowConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "linker", 3 | "name": "Flow linker", 4 | "url": "linker", 5 | "description": "Link your workflows." 6 | } -------------------------------------------------------------------------------- /web/linker/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Flow Linker 8 | 93 | 94 | 95 | 96 |
97 | 98 |
99 | 100 |
101 | 189 | 190 | 191 | -------------------------------------------------------------------------------- /web/linker/linker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow Linker 6 | 7 | 8 | 9 |
10 |
11 | 12 |
13 |
14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 |
22 |
23 |
24 | 25 |
26 | 27 |
28 | 29 | 30 |
31 | 32 |
33 | 34 |
35 | 36 |
37 |
38 |
39 | 40 |
41 |

Flow Information

42 | 43 | 44 |
45 | Thumbnail Preview 46 |
Flow Thumbnail
47 |
48 | 49 |
50 | 51 |
52 |
53 |
54 |
55 |

Flow Configuration

56 |
57 |
58 |
59 |
60 | 61 |
62 |
63 |
64 |

Node Selection

65 | 66 |
67 | 68 |
69 | 70 |
71 | 72 |
73 |
74 | 75 |
76 |
77 |
78 |
79 |
80 |

Extra Components

81 | 82 |
83 |
84 |

Added Components

85 |
86 |
87 | 92 |
93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /web/linker/media/thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diStyApps/ComfyUI-disty-Flow/5acb9afa8e545227f1fed353e1a0aaa8791e4cc1/web/linker/media/thumbnail.jpg -------------------------------------------------------------------------------- /web/linker/multiComponentHandler.js: -------------------------------------------------------------------------------- 1 | import { updateWorkflowConfig } from './configHandler.js'; 2 | import { nodeSelectDisplayComponentForm, updateComponentsList } from './componentHandler.js'; 3 | 4 | function initializeMultiComponentHandler(state) { 5 | const createButton = document.getElementById('createMultiComponentButton'); 6 | if (createButton) { 7 | createButton.addEventListener('click', () => displayMultiComponentForm(state)); 8 | } 9 | } 10 | 11 | function displayMultiComponentForm(state, existingMultiComponent = null, multiIndex = null) { 12 | const nodeToComponentSection = document.getElementById('nodeToComponentSection'); 13 | if (!nodeToComponentSection) return; 14 | 15 | nodeToComponentSection.innerHTML = generateMultiComponentFormHTML(state, existingMultiComponent); 16 | attachMultiComponentFormEvents(state, existingMultiComponent, multiIndex); 17 | } 18 | 19 | function generateMultiComponentFormHTML(state, existingMultiComponent = null) { 20 | const existingComponentsOptions = generateExistingComponentsOptions(state); 21 | 22 | const multiComponentName = existingMultiComponent ? existingMultiComponent.label : ''; 23 | const buttonText = existingMultiComponent ? 'Update MultiComponent' : 'Save MultiComponent'; 24 | 25 | return ` 26 |

${existingMultiComponent ? 'Edit MultiComponent' : 'Create MultiComponent'}

27 |
28 | 29 | 30 |
31 |
32 | 33 | 37 | 38 |
39 |
40 |

Components in MultiComponent

41 | 42 |
43 | 44 | `; 45 | } 46 | 47 | function generateExistingComponentsOptions(state) { 48 | const options = []; 49 | state.assignedComponents.forEach((assignedComp, index) => { 50 | const { nodeId, component } = assignedComp; 51 | if (!component.inMultiComponent) { 52 | options.push( 53 | `` 54 | ); 55 | } 56 | }); 57 | return options.join(''); 58 | } 59 | 60 | function attachMultiComponentFormEvents(state, existingMultiComponent = null, multiIndex = null) { 61 | const existingComponentsSelect = document.getElementById('existingComponentsSelect'); 62 | const addComponentButton = document.getElementById('addComponentToMultiButton'); 63 | const componentsListElement = document.getElementById('multiComponentComponentsList'); 64 | const saveMultiComponentButton = document.getElementById('saveMultiComponentButton'); 65 | const multiComponentNameInput = document.getElementById('multiComponentName'); 66 | 67 | const multiComponent = existingMultiComponent ? { ...existingMultiComponent } : { 68 | id: `multiComponent${Math.random().toString(36).substring(2, 8)}`, 69 | label: '', 70 | components: [], 71 | }; 72 | 73 | if (existingMultiComponent) { 74 | multiComponent.components = existingMultiComponent.components.map(comp => ({ 75 | nodeId: comp.nodeId, 76 | component: state.assignedComponents[comp.index].component, 77 | index: comp.index, 78 | })); 79 | } 80 | 81 | updateMultiComponentComponentsList(multiComponent, state, multiIndex); 82 | updateExistingComponentsOptions(state); 83 | 84 | addComponentButton.addEventListener('click', () => { 85 | const selectedValue = existingComponentsSelect.value; 86 | if (selectedValue) { 87 | const componentIndex = parseInt(selectedValue, 10); 88 | const assignedComp = state.assignedComponents[componentIndex]; 89 | if (!assignedComp) { 90 | alert('Selected component does not exist.'); 91 | return; 92 | } 93 | const { nodeId, component } = assignedComp; 94 | 95 | state.assignedComponents[componentIndex].component.inMultiComponent = true; 96 | 97 | multiComponent.components.push({ nodeId, component, index: componentIndex }); 98 | 99 | updateMultiComponentComponentsList(multiComponent, state, multiIndex); 100 | updateExistingComponentsOptions(state); 101 | updateComponentsList(state); 102 | } 103 | }); 104 | 105 | saveMultiComponentButton.addEventListener('click', () => { 106 | multiComponent.label = multiComponentNameInput.value.trim(); 107 | if (!multiComponent.label) { 108 | alert('Please enter a name for the MultiComponent.'); 109 | return; 110 | } 111 | 112 | if (existingMultiComponent && multiIndex !== null) { 113 | state.multiComponents[multiIndex] = multiComponent; 114 | } else { 115 | state.multiComponents.push(multiComponent); 116 | } 117 | 118 | updateComponentsList(state); 119 | updateWorkflowConfig(state); 120 | attachMultiComponentListEventListeners(state); 121 | clearMultiComponentForm(); 122 | }); 123 | } 124 | 125 | function updateExistingComponentsOptions(state) { 126 | const existingComponentsSelect = document.getElementById('existingComponentsSelect'); 127 | if (!existingComponentsSelect) return; 128 | existingComponentsSelect.innerHTML = ` 129 | 130 | ${generateExistingComponentsOptions(state)} 131 | `; 132 | } 133 | 134 | function updateMultiComponentComponentsList(multiComponent, state, multiIndex) { 135 | const componentsListElement = document.getElementById('multiComponentComponentsList'); 136 | if (!componentsListElement) return; 137 | 138 | componentsListElement.innerHTML = '

Components in MultiComponent

'; 139 | multiComponent.components.forEach(({ nodeId, component, index }, idx) => { 140 | const componentItem = document.createElement('div'); 141 | componentItem.className = 'component-item'; 142 | 143 | componentItem.innerHTML = ` 144 |
145 | ${idx + 1}. ${component.params.label || component.params.id}
146 | Label: "${component.params.label || ''}"
147 | Node ID: ${nodeId} | Type: "${component.type}" 148 |
149 |
150 | 151 | 152 |
153 | `; 154 | 155 | componentsListElement.appendChild(componentItem); 156 | }); 157 | 158 | attachComponentItemEvents(multiComponent, state, multiIndex); 159 | } 160 | 161 | function attachComponentItemEvents(multiComponent, state, multiIndex) { 162 | const editButtons = document.querySelectorAll('#multiComponentComponentsList .edit-component-button'); 163 | const removeButtons = document.querySelectorAll('#multiComponentComponentsList .remove-component-button'); 164 | 165 | editButtons.forEach(button => { 166 | button.addEventListener('click', () => { 167 | const componentIndex = parseInt(button.getAttribute('data-component-index'), 10); 168 | const multiIndexAttr = button.getAttribute('data-multi-index'); 169 | const multiIdx = multiIndexAttr !== '' ? parseInt(multiIndexAttr, 10) : null; 170 | const assignedComp = state.assignedComponents[componentIndex]; 171 | if (!assignedComp) { 172 | alert('Component not found.'); 173 | return; 174 | } 175 | const { nodeId, component } = assignedComp; 176 | const nodeInfo = state.nodeToCustomNodeMap[nodeId]; 177 | 178 | nodeSelectDisplayComponentForm( 179 | nodeId, 180 | nodeInfo, 181 | state, 182 | component, 183 | componentIndex, 184 | 'componentsList', 185 | multiIdx 186 | ); 187 | }); 188 | }); 189 | 190 | removeButtons.forEach(button => { 191 | button.addEventListener('click', () => { 192 | const componentIndex = parseInt(button.getAttribute('data-component-index'), 10); 193 | removeComponentFromMultiComponent(multiComponent, componentIndex, state); 194 | updateComponentsList(state); 195 | }); 196 | }); 197 | } 198 | 199 | function removeComponentFromMultiComponent(multiComponent, componentIndex, state) { 200 | multiComponent.components = multiComponent.components.filter( 201 | item => item.index !== componentIndex 202 | ); 203 | 204 | state.assignedComponents[componentIndex].component.inMultiComponent = false; 205 | 206 | updateMultiComponentComponentsList(multiComponent, state, null); 207 | updateExistingComponentsOptions(state); 208 | } 209 | 210 | function deleteMultiComponent(multiIndex, state) { 211 | const multiComponent = state.multiComponents[multiIndex]; 212 | 213 | multiComponent.components.forEach(({ index }) => { 214 | if (state.assignedComponents[index]) { 215 | state.assignedComponents[index].component.inMultiComponent = false; 216 | } 217 | }); 218 | 219 | state.multiComponents.splice(multiIndex, 1); 220 | 221 | updateComponentsList(state); 222 | updateWorkflowConfig(state); 223 | attachMultiComponentListEventListeners(state); 224 | } 225 | 226 | function attachMultiComponentListEventListeners(state) { 227 | const componentsListElement = document.getElementById('componentsList'); 228 | if (!componentsListElement) return; 229 | 230 | const editMultiButtons = componentsListElement.querySelectorAll('.edit-multicomponent-button'); 231 | const deleteMultiButtons = componentsListElement.querySelectorAll('.delete-multicomponent-button'); 232 | 233 | editMultiButtons.forEach(button => { 234 | button.addEventListener('click', () => { 235 | const multiIndex = parseInt(button.getAttribute('data-multi-index'), 10); 236 | const multiComponent = state.multiComponents[multiIndex]; 237 | displayMultiComponentForm(state, multiComponent, multiIndex); 238 | }); 239 | }); 240 | 241 | deleteMultiButtons.forEach(button => { 242 | button.addEventListener('click', () => { 243 | const multiIndex = parseInt(button.getAttribute('data-multi-index'), 10); 244 | if (confirm(`Are you sure you want to delete the MultiComponent '${state.multiComponents[multiIndex].label}'?`)) { 245 | deleteMultiComponent(multiIndex, state); 246 | } 247 | }); 248 | }); 249 | } 250 | 251 | function clearMultiComponentForm() { 252 | const nodeToComponentSection = document.getElementById('nodeToComponentSection'); 253 | if (nodeToComponentSection) nodeToComponentSection.innerHTML = ''; 254 | } 255 | 256 | export { initializeMultiComponentHandler, attachMultiComponentListEventListeners }; 257 | -------------------------------------------------------------------------------- /web/linker/nodeHandler.js: -------------------------------------------------------------------------------- 1 | 2 | import { nodeSelectDisplayComponentForm } from './componentHandler.js'; 3 | 4 | function displayNodes(state) { 5 | const nodeDropdown = document.getElementById('nodeDropdown'); 6 | if (!nodeDropdown) { 7 | console.error('Node dropdown element not found.'); 8 | return; 9 | } 10 | 11 | clearElementChildren(nodeDropdown); 12 | 13 | const nodeEntries = Object.entries(state.nodeToCustomNodeMap); 14 | if (nodeEntries.length === 0) { 15 | alert('Upload a valid workflow in API format.'); 16 | console.warn('No nodes available to display.'); 17 | return; 18 | } 19 | 20 | nodeEntries.forEach(([nodeId, nodeInfo], index) => { 21 | const option = createNodeOption(nodeId, nodeInfo, index + 1); 22 | nodeDropdown.appendChild(option); 23 | }); 24 | 25 | 26 | nodeDropdown.removeEventListener('change', handleNodeDropdownChange); 27 | nodeDropdown.addEventListener('change', (event) => handleNodeDropdownChange(event, state)); 28 | 29 | selectFirstNodeOption(nodeDropdown); 30 | } 31 | 32 | function handleNodeDropdownChange(event, state) { 33 | const selectedNodeId = event.target.value; 34 | const selectedNodeInfo = state.nodeToCustomNodeMap[selectedNodeId]; 35 | 36 | if (selectedNodeId && selectedNodeInfo) { 37 | 38 | displayNodeInfo(selectedNodeId, selectedNodeInfo, state); 39 | 40 | 41 | nodeSelectDisplayComponentForm(selectedNodeId, selectedNodeInfo, state); 42 | } else { 43 | console.error(`No information found for node ID: ${selectedNodeId}`); 44 | } 45 | } 46 | 47 | function displayNodeInfo(nodeId, nodeInfo, state) { 48 | const nodeInfoElement = document.getElementById('nodeInfo'); 49 | const nodeInfoExtraElement = document.getElementById('nodeInfo-extra'); 50 | 51 | if (!nodeInfoElement) { 52 | console.error('Node info element not found.'); 53 | return; 54 | } 55 | 56 | if (!nodeInfoExtraElement) { 57 | console.error('Node info extra element not found.'); 58 | return; 59 | } 60 | 61 | nodeInfoElement.innerHTML = generateNodeInfoHTML(nodeId, nodeInfo); 62 | nodeInfoExtraElement.innerHTML = generateNodeInfoExtraHTML(nodeId, nodeInfo); 63 | } 64 | 65 | function createNodeOption(nodeId, nodeInfo, index) { 66 | const option = document.createElement('option'); 67 | option.value = nodeId; 68 | option.textContent = `Node ${index}: ${nodeId} (${nodeInfo.classType})`; 69 | return option; 70 | } 71 | 72 | function clearElementChildren(element) { 73 | while (element.firstChild) { 74 | element.removeChild(element.firstChild); 75 | } 76 | } 77 | 78 | function selectFirstNodeOption(nodeDropdown) { 79 | if (nodeDropdown.options.length > 0) { 80 | nodeDropdown.selectedIndex = 0; 81 | nodeDropdown.dispatchEvent(new Event('change')); 82 | } 83 | } 84 | 85 | function generateNodeInfoHTML(nodeId, nodeInfo) { 86 | const { classType, pythonModule = 'N/A', count = 0, inputPaths = {} } = nodeInfo; 87 | 88 | const inputsHTML = Object.entries(inputPaths) 89 | .map(([name, path]) => `
  • ${name}: ${path}
  • `) 90 | .join(''); 91 | return ` 92 | `; 93 | } 94 | 95 | function generateNodeInfoExtraHTML(nodeId, nodeInfo) { 96 | const { classType, pythonModule = 'N/A', count = 0, inputPaths = {} } = nodeInfo; 97 | 98 | const inputsHTML = Object.entries(inputPaths) 99 | .map(([name, path]) => `
  • ${name}: ${path}
  • `) 100 | .join(''); 101 | 102 | return ` 103 |
    104 |

    Node Info

    105 |

    Node ID: ${nodeId}

    106 |

    Type: ${classType}

    107 |

    Module: ${pythonModule}

    108 |

    Count: ${count}

    109 |
    110 |

    Inputs:

    111 | 112 | `; 113 | } 114 | 115 | export { displayNodes, displayNodeInfo }; 116 | -------------------------------------------------------------------------------- /web/linker/wf.json: -------------------------------------------------------------------------------- 1 | { 2 | "3": { 3 | "inputs": { 4 | "seed": 44460315132590, 5 | "steps": 20, 6 | "cfg": 8, 7 | "sampler_name": "euler", 8 | "scheduler": "normal", 9 | "denoise": 1, 10 | "model": [ 11 | "4", 12 | 0 13 | ], 14 | "positive": [ 15 | "6", 16 | 0 17 | ], 18 | "negative": [ 19 | "7", 20 | 0 21 | ], 22 | "latent_image": [ 23 | "5", 24 | 0 25 | ] 26 | }, 27 | "class_type": "KSampler", 28 | "_meta": { 29 | "title": "KSampler" 30 | } 31 | }, 32 | "4": { 33 | "inputs": { 34 | "ckpt_name": "tamarinXL_v10.safetensors" 35 | }, 36 | "class_type": "CheckpointLoaderSimple", 37 | "_meta": { 38 | "title": "Load Checkpoint" 39 | } 40 | }, 41 | "5": { 42 | "inputs": { 43 | "width": 512, 44 | "height": 512, 45 | "batch_size": 1 46 | }, 47 | "class_type": "EmptyLatentImage", 48 | "_meta": { 49 | "title": "Empty Latent Image" 50 | } 51 | }, 52 | "6": { 53 | "inputs": { 54 | "text": "A cartoon happy goat with purple eyes and a black horn in the jungle", 55 | "clip": [ 56 | "4", 57 | 1 58 | ] 59 | }, 60 | "class_type": "CLIPTextEncode", 61 | "_meta": { 62 | "title": "CLIP Text Encode (Prompt)" 63 | } 64 | }, 65 | "7": { 66 | "inputs": { 67 | "text": "text, watermark", 68 | "clip": [ 69 | "4", 70 | 1 71 | ] 72 | }, 73 | "class_type": "CLIPTextEncode", 74 | "_meta": { 75 | "title": "CLIP Text Encode (Prompt)" 76 | } 77 | }, 78 | "8": { 79 | "inputs": { 80 | "samples": [ 81 | "3", 82 | 0 83 | ], 84 | "vae": [ 85 | "4", 86 | 2 87 | ] 88 | }, 89 | "class_type": "VAEDecode", 90 | "_meta": { 91 | "title": "VAE Decode" 92 | } 93 | }, 94 | "9": { 95 | "inputs": { 96 | "filename_prefix": "Flow", 97 | "images": [ 98 | "8", 99 | 0 100 | ] 101 | }, 102 | "class_type": "SaveImage", 103 | "_meta": { 104 | "title": "Save Image" 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /web/linker/workflowHandler.js: -------------------------------------------------------------------------------- 1 | function displayWorkflowFormat() { 2 | const workflowFormatElement = document.getElementById('workflowFormat'); 3 | if (!workflowFormatElement) return; 4 | workflowFormatElement.innerHTML = '

    Loaded API version

    '; 5 | } 6 | export { displayWorkflowFormat }; 7 | --------------------------------------------------------------------------------