plaidam commited on
Commit
3719834
·
verified ·
1 Parent(s): a1c7727

Upload 1182 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +10 -0
  2. custom_nodes/ComfyUI-Custom-Scripts/.github/workflows/publish.yml +21 -0
  3. custom_nodes/ComfyUI-Custom-Scripts/.gitignore +5 -0
  4. custom_nodes/ComfyUI-Custom-Scripts/LICENSE +21 -0
  5. custom_nodes/ComfyUI-Custom-Scripts/README.md +160 -0
  6. custom_nodes/ComfyUI-Custom-Scripts/__init__.py +25 -0
  7. custom_nodes/ComfyUI-Custom-Scripts/py/autocomplete.py +29 -0
  8. custom_nodes/ComfyUI-Custom-Scripts/py/better_combos.py +197 -0
  9. custom_nodes/ComfyUI-Custom-Scripts/py/constrain_image.py +71 -0
  10. custom_nodes/ComfyUI-Custom-Scripts/py/constrain_image_for_video.py +72 -0
  11. custom_nodes/ComfyUI-Custom-Scripts/py/math_expression.py +252 -0
  12. custom_nodes/ComfyUI-Custom-Scripts/py/model_info.py +115 -0
  13. custom_nodes/ComfyUI-Custom-Scripts/py/play_sound.py +42 -0
  14. custom_nodes/ComfyUI-Custom-Scripts/py/repeater.py +46 -0
  15. custom_nodes/ComfyUI-Custom-Scripts/py/reroute_primitive.py +59 -0
  16. custom_nodes/ComfyUI-Custom-Scripts/py/show_text.py +49 -0
  17. custom_nodes/ComfyUI-Custom-Scripts/py/string_function.py +49 -0
  18. custom_nodes/ComfyUI-Custom-Scripts/py/system_notification.py +41 -0
  19. custom_nodes/ComfyUI-Custom-Scripts/py/text_files.py +200 -0
  20. custom_nodes/ComfyUI-Custom-Scripts/py/workflows.py +61 -0
  21. custom_nodes/ComfyUI-Custom-Scripts/pyproject.toml +13 -0
  22. custom_nodes/ComfyUI-Custom-Scripts/pysssss.default.json +4 -0
  23. custom_nodes/ComfyUI-Custom-Scripts/pysssss.example.json +7 -0
  24. custom_nodes/ComfyUI-Custom-Scripts/pysssss.py +300 -0
  25. custom_nodes/ComfyUI-Custom-Scripts/user/text_file_dirs.json +5 -0
  26. custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/canvas2svg.js +1192 -0
  27. custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/favicon-active.ico +0 -0
  28. custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/favicon.ico +0 -0
  29. custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/notify.mp3 +0 -0
  30. custom_nodes/ComfyUI-Custom-Scripts/web/js/autocompleter.js +602 -0
  31. custom_nodes/ComfyUI-Custom-Scripts/web/js/betterCombos.js +370 -0
  32. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/autocomplete.css +62 -0
  33. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/autocomplete.js +692 -0
  34. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/binding.js +244 -0
  35. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/lightbox.css +102 -0
  36. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/lightbox.js +149 -0
  37. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/modelInfoDialog.css +119 -0
  38. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/modelInfoDialog.js +358 -0
  39. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/spinner.css +35 -0
  40. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/spinner.js +9 -0
  41. custom_nodes/ComfyUI-Custom-Scripts/web/js/common/utils.js +30 -0
  42. custom_nodes/ComfyUI-Custom-Scripts/web/js/contextMenuHook.js +90 -0
  43. custom_nodes/ComfyUI-Custom-Scripts/web/js/customColors.js +98 -0
  44. custom_nodes/ComfyUI-Custom-Scripts/web/js/faviconStatus.js +58 -0
  45. custom_nodes/ComfyUI-Custom-Scripts/web/js/graphArrange.js +91 -0
  46. custom_nodes/ComfyUI-Custom-Scripts/web/js/imageFeed.js +589 -0
  47. custom_nodes/ComfyUI-Custom-Scripts/web/js/kSamplerAdvDenoise.js +54 -0
  48. custom_nodes/ComfyUI-Custom-Scripts/web/js/linkRenderMode.js +57 -0
  49. custom_nodes/ComfyUI-Custom-Scripts/web/js/locking.js +186 -0
  50. custom_nodes/ComfyUI-Custom-Scripts/web/js/mathExpression.js +44 -0
.gitattributes CHANGED
@@ -33,3 +33,13 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ custom_nodes/ComfyUI_LayerStyle/font/Alibaba-PuHuiTi-Heavy.ttf filter=lfs diff=lfs merge=lfs -text
37
+ custom_nodes/ComfyUI_LayerStyle/workflow/1280x768_city.png filter=lfs diff=lfs merge=lfs -text
38
+ custom_nodes/ComfyUI_LayerStyle/workflow/1344x768_beach.png filter=lfs diff=lfs merge=lfs -text
39
+ custom_nodes/ComfyUI_LayerStyle/workflow/1344x768_hair.png filter=lfs diff=lfs merge=lfs -text
40
+ custom_nodes/ComfyUI_LayerStyle/workflow/768x1344_beach.png filter=lfs diff=lfs merge=lfs -text
41
+ custom_nodes/ComfyUI_LayerStyle/workflow/768x1344_dress.png filter=lfs diff=lfs merge=lfs -text
42
+ custom_nodes/ComfyUI_LayerStyle/workflow/girl_dino_1024.png filter=lfs diff=lfs merge=lfs -text
43
+ custom_nodes/was-node-suite-comfyui/repos/SAM/assets/masks1.png filter=lfs diff=lfs merge=lfs -text
44
+ custom_nodes/was-node-suite-comfyui/repos/SAM/assets/minidemo.gif filter=lfs diff=lfs merge=lfs -text
45
+ custom_nodes/was-node-suite-comfyui/repos/SAM/assets/notebook2.png filter=lfs diff=lfs merge=lfs -text
custom_nodes/ComfyUI-Custom-Scripts/.github/workflows/publish.yml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish to Comfy registry
2
+ on:
3
+ workflow_dispatch:
4
+ push:
5
+ branches:
6
+ - main
7
+ paths:
8
+ - "pyproject.toml"
9
+
10
+ jobs:
11
+ publish-node:
12
+ name: Publish Custom Node to registry
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Check out code
16
+ uses: actions/checkout@v4
17
+ - name: Publish Custom Node
18
+ uses: Comfy-Org/publish-node-action@main
19
+ with:
20
+ ## Add your own personal access token to your Github Repository secrets and reference it here.
21
+ personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
custom_nodes/ComfyUI-Custom-Scripts/.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__
2
+ pysssss.json
3
+ user/autocomplete.txt
4
+ web/js/assets/favicon.user.ico
5
+ web/js/assets/favicon-active.user.ico
custom_nodes/ComfyUI-Custom-Scripts/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2023 pythongosssss
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
custom_nodes/ComfyUI-Custom-Scripts/README.md ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ComfyUI-Custom-Scripts
2
+
3
+ ### ⚠️ While these extensions work for the most part, i'm very busy at the moment and so unable to keep on top of everything here, thanks for your patience!
4
+
5
+ # Installation
6
+
7
+ 1. Clone the repository:
8
+ `git clone https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git`
9
+ to your ComfyUI `custom_nodes` directory
10
+
11
+ The script will then automatically install all custom scripts and nodes.
12
+ It will attempt to use symlinks and junctions to prevent having to copy files and keep them up to date.
13
+
14
+ - For uninstallation:
15
+ - Delete the cloned repo in `custom_nodes`
16
+ - Ensure `web/extensions/pysssss/CustomScripts` has also been removed
17
+
18
+ # Update
19
+ 1. Navigate to the cloned repo e.g. `custom_nodes/ComfyUI-Custom-Scripts`
20
+ 2. `git pull`
21
+
22
+ # Features
23
+
24
+ ## Autocomplete
25
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/b5971135-414f-4f4e-a6cf-2650dc01085f)
26
+ Provides embedding and custom word autocomplete. You can view embedding details by clicking on the info icon on the list.
27
+ Define your list of custom words via the settings.
28
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/160ef61c-7d7e-49d0-b60f-5a1501b74c9d)
29
+ You can quickly default to danbooru tags using the Load button, or load/manage other custom word lists.
30
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/cc180b35-5f45-442f-9285-3ddf3fa320d0)
31
+
32
+ ## Auto Arrange Graph
33
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/04b06081-ca6f-4c0f-8584-d0a157c36747)
34
+ Adds a menu option to auto arrange the graph in order of execution, this makes very wide graphs!
35
+
36
+ ## Always Snap to Grid
37
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/66f36d1f-e579-4959-9880-9a9624922e3a)
38
+ Adds a setting to make moving nodes always snap to grid.
39
+
40
+ ## [Testing] "Better" Loader Lists
41
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/664caa71-f25f-4a96-a04a-1466d6b2b8b4)
42
+ Adds custom Lora and Checkpoint loader nodes, these have the ability to show preview images, just place a png or jpg next to the file and it'll display in the list on hover (e.g. sdxl.safetensors and sdxl.png).
43
+ Optionally enable subfolders via the settings:
44
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/e15b5e83-4f9d-4d57-8324-742bedf75439)
45
+ Adds an "examples" widget to load sample prompts, triggerwords, etc:
46
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ad1751e4-4c85-42e7-9490-e94fb1cbc8e7)
47
+ These should be stored in a folder matching the name of the model, e.g. if it is `loras/add_detail.safetensors` put your files in as `loras/add_detail/*.txt`
48
+ To quickly save a generated image as the preview to use for the model, you can right click on an image on a node, and select Save as Preview and choose the model to save the preview for:
49
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/9fa8e9db-27b3-45cb-85c2-0860a238fd3a)
50
+
51
+ ## Checkpoint/LoRA/Embedding Info
52
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/6b67bf40-ee17-4fa6-a0c1-7947066bafc2)
53
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/32405df6-b367-404f-a5df-2d4347089a9e)
54
+ Adds "View Info" menu option to view details about the selected LoRA or Checkpoint. To view embedding details, click the info button when using embedding autocomplete.
55
+
56
+ ## Constrain Image
57
+ Adds a node for resizing an image to a max & min size optionally cropping if required.
58
+
59
+ ## Custom Colors
60
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/fa7883f3-f81c-49f6-9ab6-9526e4debab6)
61
+ Adds a custom color picker to nodes & groups
62
+
63
+ ## Favicon Status
64
+ ![image](https://user-images.githubusercontent.com/125205205/230171227-31f061a6-6324-4976-bed9-723a87500cf3.png)
65
+ ![image](https://user-images.githubusercontent.com/125205205/230171445-c7202a45-b511-4d69-87fa-945ad44c063f.png)
66
+ Adds a favicon and title to the window, favicon changes color while generating and the window title includes the number of prompts in the queue
67
+
68
+ ## Image Feed
69
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/caea0d48-85b9-4ca9-9771-5c795db35fbc)
70
+ Adds a panel showing images that have been generated in the current session, you can control the direction that images are added and the position of the panel via the ComfyUI settings screen and the size of the panel and the images via the sliders at the top of the panel.
71
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ca093d38-41a3-4647-9223-5bd0b9ee4f1e)
72
+
73
+ ## KSampler (Advanced) denoise helper
74
+ Provides a simple method to set custom denoise on the advanced sampler
75
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/42946bd8-0078-4c7a-bfe9-7adb1382b5e2)
76
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/7cfccb22-f155-4848-934b-a2b2a6efe16f)
77
+
78
+ ## Math Expression
79
+ Allows for evaluating complex expressions using values from the graph. You can input `INT`, `FLOAT`, `IMAGE` and `LATENT` values.
80
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/1593edde-67b8-45d8-88cb-e75f52dba039)
81
+ Other nodes values can be referenced via the `Node name for S&R` via the `Properties` menu item on a node, or the node title.
82
+ Supported operators: `+ - * /` (basic ops) `//` (floor division) `**` (power) `^` (xor) `%` (mod)
83
+ Supported functions `floor(num, dp?)` `floor(num)` `ceil(num)` `randomint(min,max)`
84
+ If using a `LATENT` or `IMAGE` you can get the dimensions using `a.width` or `a.height` where `a` is the input name.
85
+
86
+ ## Node Finder
87
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/177d2b67-acbc-4ec3-ab31-7c295a98c194)
88
+ Adds a menu item for following/jumping to the executing node, and a menu to quickly go to a node of a specific type.
89
+
90
+ ## Preset Text
91
+ ![image](https://user-images.githubusercontent.com/125205205/230173939-08459efc-785b-46da-93d1-b02f0300c6f4.png)
92
+ Adds a node that lets you save and use text presets (e.g. for your 'normal' negatives)
93
+
94
+ ## Quick Nodes
95
+ ![image](https://user-images.githubusercontent.com/125205205/230174266-5232831a-a03b-4bf7-bc8b-c45466a0bc64.png)
96
+ Adds various menu items to some nodes for quickly setting up common parts of graphs
97
+
98
+ ## Play Sound
99
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/9bcf9fb3-5898-4432-a974-fb1e17d3b7e8)
100
+ Plays a sound when the node is executed, either after each prompt or only when the queue is empty for queuing multiple prompts.
101
+ You can customize the sound by replacing the mp3 file `ComfyUI/custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/notify.mp3`
102
+
103
+ ## System Notification
104
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/30354775/993fd783-5cd6-4779-aa97-173bc06cc405)
105
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/30354775/e45227fb-5714-4f45-b96b-6601902ef6e2)
106
+
107
+ Sends a system notification via the browser when the node is executed, either after each prompt or only when the queue is empty for queuing multiple prompts.
108
+
109
+ ## [WIP] Repeater
110
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ec0dac25-14e4-4d44-b975-52193656709d)
111
+ Node allows you to either create a list of N repeats of the input node, or create N outputs from the input node.
112
+ You can optionally decide if you want to reuse the input node, or create a new instance each time (e.g. a Checkpoint Loader would want to be re-used, but a random number would want to be unique)
113
+ TODO: Type safety on the wildcard outputs to require match with input
114
+
115
+ ## Show Text
116
+ ![image](https://user-images.githubusercontent.com/125205205/230174888-c004fd48-da78-4de9-81c2-93a866fcfcd1.png)
117
+ Takes input from a node that produces a string and displays it, useful for things like interrogator, prompt generators, etc.
118
+
119
+ ## Show Image on Menu
120
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/b6ab58f2-583b-448c-bcfc-f93f5cdab0fc)
121
+ Shows the current generating image on the menu at the bottom, you can disable this via the settings menu.
122
+
123
+ ## String Function
124
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/01107137-8a93-4765-bae0-fcc110a09091)
125
+ Supports appending and replacing text
126
+ `tidy_tags` will add commas between parts when in `append` mode.
127
+ `replace` mode supports regex replace by using `/your regex here/` and you can reference capturing groups using `\number` e.g. `\1`
128
+
129
+ ## Touch Support
130
+ Provides basic support for touch screen devices, its not perfect but better than nothing
131
+
132
+ ## Widget Defaults
133
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/3d675032-2b19-4da8-a7d7-fa2d7c555daa)
134
+ Allows you to specify default values for widgets when adding new nodes, the values are configured via the settings menu
135
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/7b57a3d8-98d3-46e9-9b33-6645c0da41e7)
136
+
137
+ ## Workflows
138
+ Adds options to the menu for saving + loading workflows:
139
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/7b5a3012-4c59-47c6-8eea-85cf534403ea)
140
+
141
+ ## Workflow Images
142
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/06453fd2-c020-46ee-a7db-2b8bf5bcba7e)
143
+ Adds menu options for importing/exporting the graph as SVG and PNG showing a view of the nodes
144
+
145
+ ## (Testing) Reroute Primitive
146
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/8b870eef-d572-43f9-b394-cfa7abbd2f98) Provides a node that allows rerouting primitives.
147
+ The node can also be collapsed to a single point that you can drag around.
148
+ ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/a9bd0112-cf8f-44f3-af6d-f9a8fed152a7)
149
+ Warning: Don't use normal reroutes or primitives with these nodes, it isn't tested and this node replaces their functionality.
150
+
151
+ <br>
152
+ <br>
153
+
154
+
155
+ ## WD14 Tagger
156
+ Moved to: https://github.com/pythongosssss/ComfyUI-WD14-Tagger
157
+
158
+ ## ~~Lock Nodes & Groups~~
159
+ This is now a standard feature of ComfyUI
160
+ ~~Adds a lock option to nodes & groups that prevents you from moving them until unlocked~~
custom_nodes/ComfyUI-Custom-Scripts/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib.util
2
+ import glob
3
+ import os
4
+ import sys
5
+ from .pysssss import init, get_ext_dir
6
+
7
+ NODE_CLASS_MAPPINGS = {}
8
+ NODE_DISPLAY_NAME_MAPPINGS = {}
9
+
10
+ if init():
11
+ py = get_ext_dir("py")
12
+ files = glob.glob(os.path.join(py, "*.py"), recursive=False)
13
+ for file in files:
14
+ name = os.path.splitext(file)[0]
15
+ spec = importlib.util.spec_from_file_location(name, file)
16
+ module = importlib.util.module_from_spec(spec)
17
+ sys.modules[name] = module
18
+ spec.loader.exec_module(module)
19
+ if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None:
20
+ NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
21
+ if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None:
22
+ NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
23
+
24
+ WEB_DIRECTORY = "./web"
25
+ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
custom_nodes/ComfyUI-Custom-Scripts/py/autocomplete.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from server import PromptServer
2
+ from aiohttp import web
3
+ import os
4
+ import folder_paths
5
+
6
+ dir = os.path.abspath(os.path.join(__file__, "../../user"))
7
+ if not os.path.exists(dir):
8
+ os.mkdir(dir)
9
+ file = os.path.join(dir, "autocomplete.txt")
10
+
11
+
12
+ @PromptServer.instance.routes.get("/pysssss/autocomplete")
13
+ async def get_autocomplete(request):
14
+ if os.path.isfile(file):
15
+ return web.FileResponse(file)
16
+ return web.Response(status=404)
17
+
18
+
19
+ @PromptServer.instance.routes.post("/pysssss/autocomplete")
20
+ async def update_autocomplete(request):
21
+ with open(file, "w", encoding="utf-8") as f:
22
+ f.write(await request.text())
23
+ return web.Response(status=200)
24
+
25
+
26
+ @PromptServer.instance.routes.get("/pysssss/loras")
27
+ async def get_loras(request):
28
+ loras = folder_paths.get_filename_list("loras")
29
+ return web.json_response(list(map(lambda a: os.path.splitext(a)[0], loras)))
custom_nodes/ComfyUI-Custom-Scripts/py/better_combos.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import glob
2
+ import os
3
+ from nodes import LoraLoader, CheckpointLoaderSimple
4
+ import folder_paths
5
+ from server import PromptServer
6
+ from folder_paths import get_directory_by_type
7
+ from aiohttp import web
8
+ import shutil
9
+
10
+
11
+ @PromptServer.instance.routes.get("/pysssss/view/{name}")
12
+ async def view(request):
13
+ name = request.match_info["name"]
14
+ pos = name.index("/")
15
+ type = name[0:pos]
16
+ name = name[pos+1:]
17
+
18
+ image_path = folder_paths.get_full_path(
19
+ type, name)
20
+ if not image_path:
21
+ return web.Response(status=404)
22
+
23
+ filename = os.path.basename(image_path)
24
+ return web.FileResponse(image_path, headers={"Content-Disposition": f"filename=\"{filename}\""})
25
+
26
+
27
+ @PromptServer.instance.routes.post("/pysssss/save/{name}")
28
+ async def save_preview(request):
29
+ name = request.match_info["name"]
30
+ pos = name.index("/")
31
+ type = name[0:pos]
32
+ name = name[pos+1:]
33
+
34
+ body = await request.json()
35
+
36
+ dir = get_directory_by_type(body.get("type", "output"))
37
+ subfolder = body.get("subfolder", "")
38
+ full_output_folder = os.path.join(dir, os.path.normpath(subfolder))
39
+
40
+ filepath = os.path.join(full_output_folder, body.get("filename", ""))
41
+
42
+ if os.path.commonpath((dir, os.path.abspath(filepath))) != dir:
43
+ return web.Response(status=400)
44
+
45
+ image_path = folder_paths.get_full_path(type, name)
46
+ image_path = os.path.splitext(
47
+ image_path)[0] + os.path.splitext(filepath)[1]
48
+
49
+ shutil.copyfile(filepath, image_path)
50
+
51
+ return web.json_response({
52
+ "image": type + "/" + os.path.basename(image_path)
53
+ })
54
+
55
+
56
+ @PromptServer.instance.routes.get("/pysssss/examples/{name}")
57
+ async def get_examples(request):
58
+ name = request.match_info["name"]
59
+ pos = name.index("/")
60
+ type = name[0:pos]
61
+ name = name[pos+1:]
62
+
63
+ file_path = folder_paths.get_full_path(
64
+ type, name)
65
+ if not file_path:
66
+ return web.Response(status=404)
67
+
68
+ file_path_no_ext = os.path.splitext(file_path)[0]
69
+ examples = []
70
+
71
+ if os.path.isdir(file_path_no_ext):
72
+ examples += sorted(map(lambda t: os.path.relpath(t, file_path_no_ext),
73
+ glob.glob(file_path_no_ext + "/*.txt")))
74
+
75
+ if os.path.isfile(file_path_no_ext + ".txt"):
76
+ examples += ["notes"]
77
+
78
+ return web.json_response(examples)
79
+
80
+ @PromptServer.instance.routes.post("/pysssss/examples/{name}")
81
+ async def save_example(request):
82
+ name = request.match_info["name"]
83
+ pos = name.index("/")
84
+ type = name[0:pos]
85
+ name = name[pos+1:]
86
+ body = await request.json()
87
+ example_name = body["name"]
88
+ example = body["example"]
89
+
90
+ file_path = folder_paths.get_full_path(
91
+ type, name)
92
+ if not file_path:
93
+ return web.Response(status=404)
94
+
95
+ if not example_name.endswith(".txt"):
96
+ example_name += ".txt"
97
+
98
+ file_path_no_ext = os.path.splitext(file_path)[0]
99
+ example_file = os.path.join(file_path_no_ext, example_name)
100
+ if not os.path.exists(file_path_no_ext):
101
+ os.mkdir(file_path_no_ext)
102
+ with open(example_file, 'w', encoding='utf8') as f:
103
+ f.write(example)
104
+
105
+ return web.Response(status=201)
106
+
107
+
108
+ def populate_items(names, type):
109
+ for idx, item_name in enumerate(names):
110
+
111
+ file_name = os.path.splitext(item_name)[0]
112
+ file_path = folder_paths.get_full_path(type, item_name)
113
+
114
+ if file_path is None:
115
+ print(f"(pysssss:better_combos) Unable to get path for {type} {item_name}")
116
+ continue
117
+
118
+ file_path_no_ext = os.path.splitext(file_path)[0]
119
+
120
+ for ext in ["png", "jpg", "jpeg", "preview.png", "preview.jpeg"]:
121
+ has_image = os.path.isfile(file_path_no_ext + "." + ext)
122
+ if has_image:
123
+ item_image = f"{file_name}.{ext}"
124
+ break
125
+
126
+ names[idx] = {
127
+ "content": item_name,
128
+ "image": f"{type}/{item_image}" if has_image else None,
129
+ }
130
+ names.sort(key=lambda i: i["content"].lower())
131
+
132
+
133
+ class LoraLoaderWithImages(LoraLoader):
134
+ RETURN_TYPES = (*LoraLoader.RETURN_TYPES, "STRING",)
135
+
136
+ @classmethod
137
+ def INPUT_TYPES(s):
138
+ types = super().INPUT_TYPES()
139
+ names = types["required"]["lora_name"][0]
140
+ populate_items(names, "loras")
141
+ types["optional"] = { "prompt": ("HIDDEN",) }
142
+ return types
143
+
144
+ @classmethod
145
+ def VALIDATE_INPUTS(s, lora_name):
146
+ types = super().INPUT_TYPES()
147
+ names = types["required"]["lora_name"][0]
148
+
149
+ name = lora_name["content"]
150
+ if name in names:
151
+ return True
152
+ else:
153
+ return f"Lora not found: {name}"
154
+
155
+ def load_lora(self, **kwargs):
156
+ kwargs["lora_name"] = kwargs["lora_name"]["content"]
157
+ prompt = kwargs.pop("prompt", "")
158
+ return (*super().load_lora(**kwargs), prompt)
159
+
160
+
161
+ class CheckpointLoaderSimpleWithImages(CheckpointLoaderSimple):
162
+ RETURN_TYPES = (*CheckpointLoaderSimple.RETURN_TYPES, "STRING",)
163
+
164
+ @classmethod
165
+ def INPUT_TYPES(s):
166
+ types = super().INPUT_TYPES()
167
+ names = types["required"]["ckpt_name"][0]
168
+ populate_items(names, "checkpoints")
169
+ types["optional"] = { "prompt": ("HIDDEN",) }
170
+ return types
171
+
172
+ @classmethod
173
+ def VALIDATE_INPUTS(s, ckpt_name):
174
+ types = super().INPUT_TYPES()
175
+ names = types["required"]["ckpt_name"][0]
176
+
177
+ name = ckpt_name["content"]
178
+ if name in names:
179
+ return True
180
+ else:
181
+ return f"Checkpoint not found: {name}"
182
+
183
+ def load_checkpoint(self, **kwargs):
184
+ kwargs["ckpt_name"] = kwargs["ckpt_name"]["content"]
185
+ prompt = kwargs.pop("prompt", "")
186
+ return (*super().load_checkpoint(**kwargs), prompt)
187
+
188
+
189
+ NODE_CLASS_MAPPINGS = {
190
+ "LoraLoader|pysssss": LoraLoaderWithImages,
191
+ "CheckpointLoader|pysssss": CheckpointLoaderSimpleWithImages,
192
+ }
193
+
194
+ NODE_DISPLAY_NAME_MAPPINGS = {
195
+ "LoraLoader|pysssss": "Lora Loader 🐍",
196
+ "CheckpointLoader|pysssss": "Checkpoint Loader 🐍",
197
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/constrain_image.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import numpy as np
3
+ from PIL import Image
4
+
5
+ class ConstrainImage:
6
+ """
7
+ A node that constrains an image to a maximum and minimum size while maintaining aspect ratio.
8
+ """
9
+
10
+ @classmethod
11
+ def INPUT_TYPES(cls):
12
+ return {
13
+ "required": {
14
+ "images": ("IMAGE",),
15
+ "max_width": ("INT", {"default": 1024, "min": 0}),
16
+ "max_height": ("INT", {"default": 1024, "min": 0}),
17
+ "min_width": ("INT", {"default": 0, "min": 0}),
18
+ "min_height": ("INT", {"default": 0, "min": 0}),
19
+ "crop_if_required": (["yes", "no"], {"default": "no"}),
20
+ },
21
+ }
22
+
23
+ RETURN_TYPES = ("IMAGE",)
24
+ FUNCTION = "constrain_image"
25
+ CATEGORY = "image"
26
+ OUTPUT_IS_LIST = (True,)
27
+
28
+ def constrain_image(self, images, max_width, max_height, min_width, min_height, crop_if_required):
29
+ crop_if_required = crop_if_required == "yes"
30
+ results = []
31
+ for image in images:
32
+ i = 255. * image.cpu().numpy()
33
+ img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)).convert("RGB")
34
+
35
+ current_width, current_height = img.size
36
+ aspect_ratio = current_width / current_height
37
+
38
+ constrained_width = min(max(current_width, min_width), max_width)
39
+ constrained_height = min(max(current_height, min_height), max_height)
40
+
41
+ if constrained_width / constrained_height > aspect_ratio:
42
+ constrained_width = max(int(constrained_height * aspect_ratio), min_width)
43
+ if crop_if_required:
44
+ constrained_height = int(current_height / (current_width / constrained_width))
45
+ else:
46
+ constrained_height = max(int(constrained_width / aspect_ratio), min_height)
47
+ if crop_if_required:
48
+ constrained_width = int(current_width / (current_height / constrained_height))
49
+
50
+ resized_image = img.resize((constrained_width, constrained_height), Image.LANCZOS)
51
+
52
+ if crop_if_required and (constrained_width > max_width or constrained_height > max_height):
53
+ left = max((constrained_width - max_width) // 2, 0)
54
+ top = max((constrained_height - max_height) // 2, 0)
55
+ right = min(constrained_width, max_width) + left
56
+ bottom = min(constrained_height, max_height) + top
57
+ resized_image = resized_image.crop((left, top, right, bottom))
58
+
59
+ resized_image = np.array(resized_image).astype(np.float32) / 255.0
60
+ resized_image = torch.from_numpy(resized_image)[None,]
61
+ results.append(resized_image)
62
+
63
+ return (results,)
64
+
65
+ NODE_CLASS_MAPPINGS = {
66
+ "ConstrainImage|pysssss": ConstrainImage,
67
+ }
68
+
69
+ NODE_DISPLAY_NAME_MAPPINGS = {
70
+ "ConstrainImage|pysssss": "Constrain Image 🐍",
71
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/constrain_image_for_video.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import numpy as np
3
+ from PIL import Image
4
+
5
+ class ConstrainImageforVideo:
6
+ """
7
+ A node that constrains an image to a maximum and minimum size while maintaining aspect ratio.
8
+ """
9
+
10
+ @classmethod
11
+ def INPUT_TYPES(cls):
12
+ return {
13
+ "required": {
14
+ "images": ("IMAGE",),
15
+ "max_width": ("INT", {"default": 1024, "min": 0}),
16
+ "max_height": ("INT", {"default": 1024, "min": 0}),
17
+ "min_width": ("INT", {"default": 0, "min": 0}),
18
+ "min_height": ("INT", {"default": 0, "min": 0}),
19
+ "crop_if_required": (["yes", "no"], {"default": "no"}),
20
+ },
21
+ }
22
+
23
+ RETURN_TYPES = ("IMAGE",)
24
+ RETURN_NAMES = ("IMAGE",)
25
+ FUNCTION = "constrain_image_for_video"
26
+ CATEGORY = "image"
27
+
28
+ def constrain_image_for_video(self, images, max_width, max_height, min_width, min_height, crop_if_required):
29
+ crop_if_required = crop_if_required == "yes"
30
+ results = []
31
+ for image in images:
32
+ i = 255. * image.cpu().numpy()
33
+ img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)).convert("RGB")
34
+
35
+ current_width, current_height = img.size
36
+ aspect_ratio = current_width / current_height
37
+
38
+ constrained_width = max(min(current_width, min_width), max_width)
39
+ constrained_height = max(min(current_height, min_height), max_height)
40
+
41
+ if constrained_width / constrained_height > aspect_ratio:
42
+ constrained_width = max(int(constrained_height * aspect_ratio), min_width)
43
+ if crop_if_required:
44
+ constrained_height = int(current_height / (current_width / constrained_width))
45
+ else:
46
+ constrained_height = max(int(constrained_width / aspect_ratio), min_height)
47
+ if crop_if_required:
48
+ constrained_width = int(current_width / (current_height / constrained_height))
49
+
50
+ resized_image = img.resize((constrained_width, constrained_height), Image.LANCZOS)
51
+
52
+ if crop_if_required and (constrained_width > max_width or constrained_height > max_height):
53
+ left = max((constrained_width - max_width) // 2, 0)
54
+ top = max((constrained_height - max_height) // 2, 0)
55
+ right = min(constrained_width, max_width) + left
56
+ bottom = min(constrained_height, max_height) + top
57
+ resized_image = resized_image.crop((left, top, right, bottom))
58
+
59
+ resized_image = np.array(resized_image).astype(np.float32) / 255.0
60
+ resized_image = torch.from_numpy(resized_image)[None,]
61
+ results.append(resized_image)
62
+ all_images = torch.cat(results, dim=0)
63
+
64
+ return (all_images, all_images.size(0),)
65
+
66
+ NODE_CLASS_MAPPINGS = {
67
+ "ConstrainImageforVideo|pysssss": ConstrainImageforVideo,
68
+ }
69
+
70
+ NODE_DISPLAY_NAME_MAPPINGS = {
71
+ "ConstrainImageforVideo|pysssss": "Constrain Image for Video 🐍",
72
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/math_expression.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ import math
3
+ import random
4
+ import operator as op
5
+
6
+ # Hack: string type that is always equal in not equal comparisons
7
+ class AnyType(str):
8
+ def __ne__(self, __value: object) -> bool:
9
+ return False
10
+
11
+
12
+ # Our any instance wants to be a wildcard string
13
+ any = AnyType("*")
14
+
15
+ operators = {
16
+ ast.Add: op.add,
17
+ ast.Sub: op.sub,
18
+ ast.Mult: op.mul,
19
+ ast.Div: op.truediv,
20
+ ast.FloorDiv: op.floordiv,
21
+ ast.Pow: op.pow,
22
+ ast.BitXor: op.xor,
23
+ ast.USub: op.neg,
24
+ ast.Mod: op.mod,
25
+ ast.BitAnd: op.and_,
26
+ ast.BitOr: op.or_,
27
+ ast.Invert: op.invert,
28
+ ast.And: lambda a, b: 1 if a and b else 0,
29
+ ast.Or: lambda a, b: 1 if a or b else 0,
30
+ ast.Not: lambda a: 0 if a else 1,
31
+ ast.RShift: op.rshift,
32
+ ast.LShift: op.lshift
33
+ }
34
+
35
+ # TODO: restructure args to provide more info, generate hint based on args to save duplication
36
+ functions = {
37
+ "round": {
38
+ "args": (1, 2),
39
+ "call": lambda a, b = None: round(a, b),
40
+ "hint": "number, dp? = 0"
41
+ },
42
+ "ceil": {
43
+ "args": (1, 1),
44
+ "call": lambda a: math.ceil(a),
45
+ "hint": "number"
46
+ },
47
+ "floor": {
48
+ "args": (1, 1),
49
+ "call": lambda a: math.floor(a),
50
+ "hint": "number"
51
+ },
52
+ "min": {
53
+ "args": (2, None),
54
+ "call": lambda *args: min(*args),
55
+ "hint": "...numbers"
56
+ },
57
+ "max": {
58
+ "args": (2, None),
59
+ "call": lambda *args: max(*args),
60
+ "hint": "...numbers"
61
+ },
62
+ "randomint": {
63
+ "args": (2, 2),
64
+ "call": lambda a, b: random.randint(a, b),
65
+ "hint": "min, max"
66
+ },
67
+ "randomchoice": {
68
+ "args": (2, None),
69
+ "call": lambda *args: random.choice(args),
70
+ "hint": "...numbers"
71
+ },
72
+ "sqrt": {
73
+ "args": (1, 1),
74
+ "call": lambda a: math.sqrt(a),
75
+ "hint": "number"
76
+ },
77
+ "int": {
78
+ "args": (1, 1),
79
+ "call": lambda a = None: int(a),
80
+ "hint": "number"
81
+ },
82
+ "iif": {
83
+ "args": (3, 3),
84
+ "call": lambda a, b, c = None: b if a else c,
85
+ "hint": "value, truepart, falsepart"
86
+ },
87
+ }
88
+
89
+ autocompleteWords = list({
90
+ "text": x,
91
+ "value": f"{x}()",
92
+ "showValue": False,
93
+ "hint": f"{functions[x]['hint']}",
94
+ "caretOffset": -1
95
+ } for x in functions.keys())
96
+
97
+
98
+ class MathExpression:
99
+
100
+ @classmethod
101
+ def INPUT_TYPES(cls):
102
+ return {
103
+ "required": {
104
+ "expression": ("STRING", {"multiline": True, "dynamicPrompts": False, "pysssss.autocomplete": {
105
+ "words": autocompleteWords,
106
+ "separator": ""
107
+ }}),
108
+ },
109
+ "optional": {
110
+ "a": (any, ),
111
+ "b": (any,),
112
+ "c": (any, ),
113
+ },
114
+ "hidden": {"extra_pnginfo": "EXTRA_PNGINFO",
115
+ "prompt": "PROMPT"},
116
+ }
117
+
118
+ RETURN_TYPES = ("INT", "FLOAT", )
119
+ FUNCTION = "evaluate"
120
+ CATEGORY = "utils"
121
+ OUTPUT_NODE = True
122
+
123
+ @classmethod
124
+ def IS_CHANGED(s, expression, **kwargs):
125
+ if "random" in expression:
126
+ return float("nan")
127
+ return expression
128
+
129
+ def get_widget_value(self, extra_pnginfo, prompt, node_name, widget_name):
130
+ workflow = extra_pnginfo["workflow"] if "workflow" in extra_pnginfo else { "nodes": [] }
131
+ node_id = None
132
+ for node in workflow["nodes"]:
133
+ name = node["type"]
134
+ if "properties" in node:
135
+ if "Node name for S&R" in node["properties"]:
136
+ name = node["properties"]["Node name for S&R"]
137
+ if name == node_name:
138
+ node_id = node["id"]
139
+ break
140
+ if "title" in node:
141
+ name = node["title"]
142
+ if name == node_name:
143
+ node_id = node["id"]
144
+ break
145
+ if node_id is not None:
146
+ values = prompt[str(node_id)]
147
+ if "inputs" in values:
148
+ if widget_name in values["inputs"]:
149
+ value = values["inputs"][widget_name]
150
+ if isinstance(value, list):
151
+ raise ValueError("Converted widgets are not supported via named reference, use the inputs instead.")
152
+ return value
153
+ raise NameError(f"Widget not found: {node_name}.{widget_name}")
154
+ raise NameError(f"Node not found: {node_name}.{widget_name}")
155
+
156
+ def get_size(self, target, property):
157
+ if isinstance(target, dict) and "samples" in target:
158
+ # Latent
159
+ if property == "width":
160
+ return target["samples"].shape[3] * 8
161
+ return target["samples"].shape[2] * 8
162
+ else:
163
+ # Image
164
+ if property == "width":
165
+ return target.shape[2]
166
+ return target.shape[1]
167
+
168
+ def evaluate(self, expression, prompt, extra_pnginfo={}, a=None, b=None, c=None):
169
+ expression = expression.replace('\n', ' ').replace('\r', '')
170
+ node = ast.parse(expression, mode='eval').body
171
+
172
+ lookup = {"a": a, "b": b, "c": c}
173
+
174
+ def eval_op(node, l, r):
175
+ l = eval_expr(l)
176
+ r = eval_expr(r)
177
+ l = l if isinstance(l, int) else float(l)
178
+ r = r if isinstance(r, int) else float(r)
179
+ return operators[type(node.op)](l, r)
180
+
181
+ def eval_expr(node):
182
+ if isinstance(node, ast.Constant) or isinstance(node, ast.Num):
183
+ return node.n
184
+ elif isinstance(node, ast.BinOp):
185
+ return eval_op(node, node.left, node.right)
186
+ elif isinstance(node, ast.BoolOp):
187
+ return eval_op(node, node.values[0], node.values[1])
188
+ elif isinstance(node, ast.UnaryOp):
189
+ return operators[type(node.op)](eval_expr(node.operand))
190
+ elif isinstance(node, ast.Attribute):
191
+ if node.value.id in lookup:
192
+ if node.attr == "width" or node.attr == "height":
193
+ return self.get_size(lookup[node.value.id], node.attr)
194
+
195
+ return self.get_widget_value(extra_pnginfo, prompt, node.value.id, node.attr)
196
+ elif isinstance(node, ast.Name):
197
+ if node.id in lookup:
198
+ val = lookup[node.id]
199
+ if isinstance(val, (int, float, complex)):
200
+ return val
201
+ else:
202
+ raise TypeError(
203
+ f"Compex types (LATENT/IMAGE) need to reference their width/height, e.g. {node.id}.width")
204
+ raise NameError(f"Name not found: {node.id}")
205
+ elif isinstance(node, ast.Call):
206
+ if node.func.id in functions:
207
+ fn = functions[node.func.id]
208
+ l = len(node.args)
209
+ if l < fn["args"][0] or (fn["args"][1] is not None and l > fn["args"][1]):
210
+ if fn["args"][1] is None:
211
+ toErr = " or more"
212
+ else:
213
+ toErr = f" to {fn['args'][1]}"
214
+ raise SyntaxError(
215
+ f"Invalid function call: {node.func.id} requires {fn['args'][0]}{toErr} arguments")
216
+ args = []
217
+ for arg in node.args:
218
+ args.append(eval_expr(arg))
219
+ return fn["call"](*args)
220
+ raise NameError(f"Invalid function call: {node.func.id}")
221
+ elif isinstance(node, ast.Compare):
222
+ l = eval_expr(node.left)
223
+ r = eval_expr(node.comparators[0])
224
+ if isinstance(node.ops[0], ast.Eq):
225
+ return 1 if l == r else 0
226
+ if isinstance(node.ops[0], ast.NotEq):
227
+ return 1 if l != r else 0
228
+ if isinstance(node.ops[0], ast.Gt):
229
+ return 1 if l > r else 0
230
+ if isinstance(node.ops[0], ast.GtE):
231
+ return 1 if l >= r else 0
232
+ if isinstance(node.ops[0], ast.Lt):
233
+ return 1 if l < r else 0
234
+ if isinstance(node.ops[0], ast.LtE):
235
+ return 1 if l <= r else 0
236
+ raise NotImplementedError(
237
+ "Operator " + node.ops[0].__class__.__name__ + " not supported.")
238
+ else:
239
+ raise TypeError(node)
240
+
241
+ r = eval_expr(node)
242
+ return {"ui": {"value": [r]}, "result": (int(r), float(r),)}
243
+
244
+
245
+ NODE_CLASS_MAPPINGS = {
246
+ "MathExpression|pysssss": MathExpression,
247
+ }
248
+
249
+ NODE_DISPLAY_NAME_MAPPINGS = {
250
+ "MathExpression|pysssss": "Math Expression 🐍",
251
+ }
252
+
custom_nodes/ComfyUI-Custom-Scripts/py/model_info.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import json
3
+ from aiohttp import web
4
+ from server import PromptServer
5
+ import folder_paths
6
+ import os
7
+
8
+
9
+ def get_metadata(filepath):
10
+ with open(filepath, "rb") as file:
11
+ # https://github.com/huggingface/safetensors#format
12
+ # 8 bytes: N, an unsigned little-endian 64-bit integer, containing the size of the header
13
+ header_size = int.from_bytes(file.read(8), "little", signed=False)
14
+
15
+ if header_size <= 0:
16
+ raise BufferError("Invalid header size")
17
+
18
+ header = file.read(header_size)
19
+ if header_size <= 0:
20
+ raise BufferError("Invalid header")
21
+
22
+ header_json = json.loads(header)
23
+ return header_json["__metadata__"] if "__metadata__" in header_json else None
24
+
25
+
26
+ @PromptServer.instance.routes.post("/pysssss/metadata/notes/{name}")
27
+ async def save_notes(request):
28
+ name = request.match_info["name"]
29
+ pos = name.index("/")
30
+ type = name[0:pos]
31
+ name = name[pos+1:]
32
+
33
+ file_path = None
34
+ if type == "embeddings" or type == "loras":
35
+ name = name.lower()
36
+ files = folder_paths.get_filename_list(type)
37
+ for f in files:
38
+ lower_f = f.lower()
39
+ if lower_f == name:
40
+ file_path = folder_paths.get_full_path(type, f)
41
+ else:
42
+ n = os.path.splitext(f)[0].lower()
43
+ if n == name:
44
+ file_path = folder_paths.get_full_path(type, f)
45
+
46
+ if file_path is not None:
47
+ break
48
+ else:
49
+ file_path = folder_paths.get_full_path(
50
+ type, name)
51
+ if not file_path:
52
+ return web.Response(status=404)
53
+
54
+ file_no_ext = os.path.splitext(file_path)[0]
55
+ info_file = file_no_ext + ".txt"
56
+ with open(info_file, "w") as f:
57
+ f.write(await request.text())
58
+
59
+ return web.Response(status=200)
60
+
61
+
62
+ @PromptServer.instance.routes.get("/pysssss/metadata/{name}")
63
+ async def load_metadata(request):
64
+ name = request.match_info["name"]
65
+ pos = name.index("/")
66
+ type = name[0:pos]
67
+ name = name[pos+1:]
68
+
69
+ file_path = None
70
+ if type == "embeddings" or type == "loras":
71
+ name = name.lower()
72
+ files = folder_paths.get_filename_list(type)
73
+ for f in files:
74
+ lower_f = f.lower()
75
+ if lower_f == name:
76
+ file_path = folder_paths.get_full_path(type, f)
77
+ else:
78
+ n = os.path.splitext(f)[0].lower()
79
+ if n == name:
80
+ file_path = folder_paths.get_full_path(type, f)
81
+
82
+ if file_path is not None:
83
+ break
84
+ else:
85
+ file_path = folder_paths.get_full_path(
86
+ type, name)
87
+ if not file_path:
88
+ return web.Response(status=404)
89
+
90
+ try:
91
+ meta = get_metadata(file_path)
92
+ except:
93
+ meta = None
94
+
95
+ if meta is None:
96
+ meta = {}
97
+
98
+ file_no_ext = os.path.splitext(file_path)[0]
99
+
100
+ info_file = file_no_ext + ".txt"
101
+ if os.path.isfile(info_file):
102
+ with open(info_file, "r") as f:
103
+ meta["pysssss.notes"] = f.read()
104
+
105
+ hash_file = file_no_ext + ".sha256"
106
+ if os.path.isfile(hash_file):
107
+ with open(hash_file, "rt") as f:
108
+ meta["pysssss.sha256"] = f.read()
109
+ else:
110
+ with open(file_path, "rb") as f:
111
+ meta["pysssss.sha256"] = hashlib.sha256(f.read()).hexdigest()
112
+ with open(hash_file, "wt") as f:
113
+ f.write(meta["pysssss.sha256"])
114
+
115
+ return web.json_response(meta)
custom_nodes/ComfyUI-Custom-Scripts/py/play_sound.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hack: string type that is always equal in not equal comparisons
2
+ class AnyType(str):
3
+ def __ne__(self, __value: object) -> bool:
4
+ return False
5
+
6
+
7
+ # Our any instance wants to be a wildcard string
8
+ any = AnyType("*")
9
+
10
+
11
+ class PlaySound:
12
+ @classmethod
13
+ def INPUT_TYPES(s):
14
+ return {"required": {
15
+ "any": (any, {}),
16
+ "mode": (["always", "on empty queue"], {}),
17
+ "volume": ("FLOAT", {"min": 0, "max": 1, "step": 0.1, "default": 0.5}),
18
+ "file": ("STRING", { "default": "notify.mp3" })
19
+ }}
20
+
21
+ FUNCTION = "nop"
22
+ INPUT_IS_LIST = True
23
+ OUTPUT_IS_LIST = (True,)
24
+ OUTPUT_NODE = True
25
+ RETURN_TYPES = (any,)
26
+
27
+ CATEGORY = "utils"
28
+
29
+ def IS_CHANGED(self, **kwargs):
30
+ return float("NaN")
31
+
32
+ def nop(self, any, mode, volume, file):
33
+ return {"ui": {"a": []}, "result": (any,)}
34
+
35
+
36
+ NODE_CLASS_MAPPINGS = {
37
+ "PlaySound|pysssss": PlaySound,
38
+ }
39
+
40
+ NODE_DISPLAY_NAME_MAPPINGS = {
41
+ "PlaySound|pysssss": "PlaySound 🐍",
42
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/repeater.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hack: string type that is always equal in not equal comparisons
2
+ class AnyType(str):
3
+ def __ne__(self, __value: object) -> bool:
4
+ return False
5
+
6
+
7
+ # Our any instance wants to be a wildcard string
8
+ any = AnyType("*")
9
+
10
+
11
+ class Repeater:
12
+ @classmethod
13
+ def INPUT_TYPES(s):
14
+ return {"required": {
15
+ "source": (any, {}),
16
+ "repeats": ("INT", {"min": 0, "max": 5000, "default": 2}),
17
+ "output": (["single", "multi"], {}),
18
+ "node_mode": (["reuse", "create"], {}),
19
+ }}
20
+
21
+ RETURN_TYPES = (any,)
22
+ FUNCTION = "repeat"
23
+ OUTPUT_NODE = False
24
+ OUTPUT_IS_LIST = (True,)
25
+
26
+ CATEGORY = "utils"
27
+
28
+ def repeat(self, repeats, output, node_mode, **kwargs):
29
+ if output == "multi":
30
+ # Multi outputs are split to indiviual nodes on the frontend when serializing
31
+ return ([kwargs["source"]],)
32
+ elif node_mode == "reuse":
33
+ # When reusing we have a single input node, repeat that N times
34
+ return ([kwargs["source"]] * repeats,)
35
+ else:
36
+ # When creating new nodes, they'll be added dynamically when the graph is serialized
37
+ return ((list(kwargs.values())),)
38
+
39
+
40
+ NODE_CLASS_MAPPINGS = {
41
+ "Repeater|pysssss": Repeater,
42
+ }
43
+
44
+ NODE_DISPLAY_NAME_MAPPINGS = {
45
+ "Repeater|pysssss": "Repeater 🐍",
46
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/reroute_primitive.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hack: string type that is always equal in not equal comparisons
2
+ class AnyType(str):
3
+ def __ne__(self, __value: object) -> bool:
4
+ return False
5
+
6
+
7
+ # Our any instance wants to be a wildcard string
8
+ any = AnyType("*")
9
+
10
+
11
+ class ReroutePrimitive:
12
+ @classmethod
13
+ def INPUT_TYPES(cls):
14
+ return {
15
+ "required": {"value": (any, )},
16
+ }
17
+
18
+ @classmethod
19
+ def VALIDATE_INPUTS(s, **kwargs):
20
+ return True
21
+
22
+ RETURN_TYPES = (any,)
23
+ FUNCTION = "route"
24
+ CATEGORY = "__hidden__"
25
+
26
+ def route(self, value):
27
+ return (value,)
28
+
29
+
30
+ class MultiPrimitive:
31
+ @classmethod
32
+ def INPUT_TYPES(cls):
33
+ return {
34
+ "required": {},
35
+ "optional": {"value": (any, )},
36
+ }
37
+
38
+ @classmethod
39
+ def VALIDATE_INPUTS(s, **kwargs):
40
+ return True
41
+
42
+ RETURN_TYPES = (any,)
43
+ FUNCTION = "listify"
44
+ CATEGORY = "utils"
45
+ OUTPUT_IS_LIST = (True,)
46
+
47
+ def listify(self, **kwargs):
48
+ return (list(kwargs.values()),)
49
+
50
+
51
+ NODE_CLASS_MAPPINGS = {
52
+ "ReroutePrimitive|pysssss": ReroutePrimitive,
53
+ # "MultiPrimitive|pysssss": MultiPrimitive,
54
+ }
55
+
56
+ NODE_DISPLAY_NAME_MAPPINGS = {
57
+ "ReroutePrimitive|pysssss": "Reroute Primitive 🐍",
58
+ # "MultiPrimitive|pysssss": "Multi Primitive 🐍",
59
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/show_text.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class ShowText:
2
+ @classmethod
3
+ def INPUT_TYPES(s):
4
+ return {
5
+ "required": {
6
+ "text": ("STRING", {"forceInput": True}),
7
+ },
8
+ "hidden": {
9
+ "unique_id": "UNIQUE_ID",
10
+ "extra_pnginfo": "EXTRA_PNGINFO",
11
+ },
12
+ }
13
+
14
+ INPUT_IS_LIST = True
15
+ RETURN_TYPES = ("STRING",)
16
+ FUNCTION = "notify"
17
+ OUTPUT_NODE = True
18
+ OUTPUT_IS_LIST = (True,)
19
+
20
+ CATEGORY = "utils"
21
+
22
+ def notify(self, text, unique_id=None, extra_pnginfo=None):
23
+ if unique_id is not None and extra_pnginfo is not None:
24
+ if not isinstance(extra_pnginfo, list):
25
+ print("Error: extra_pnginfo is not a list")
26
+ elif (
27
+ not isinstance(extra_pnginfo[0], dict)
28
+ or "workflow" not in extra_pnginfo[0]
29
+ ):
30
+ print("Error: extra_pnginfo[0] is not a dict or missing 'workflow' key")
31
+ else:
32
+ workflow = extra_pnginfo[0]["workflow"]
33
+ node = next(
34
+ (x for x in workflow["nodes"] if str(x["id"]) == str(unique_id[0])),
35
+ None,
36
+ )
37
+ if node:
38
+ node["widgets_values"] = [text]
39
+
40
+ return {"ui": {"text": text}, "result": (text,)}
41
+
42
+
43
+ NODE_CLASS_MAPPINGS = {
44
+ "ShowText|pysssss": ShowText,
45
+ }
46
+
47
+ NODE_DISPLAY_NAME_MAPPINGS = {
48
+ "ShowText|pysssss": "Show Text 🐍",
49
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/string_function.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ class StringFunction:
4
+ @classmethod
5
+ def INPUT_TYPES(s):
6
+ return {
7
+ "required": {
8
+ "action": (["append", "replace"], {}),
9
+ "tidy_tags": (["yes", "no"], {}),
10
+ },
11
+ "optional": {
12
+ "text_a": ("STRING", {"multiline": True, "dynamicPrompts": False}),
13
+ "text_b": ("STRING", {"multiline": True, "dynamicPrompts": False}),
14
+ "text_c": ("STRING", {"multiline": True, "dynamicPrompts": False})
15
+ }
16
+ }
17
+
18
+ RETURN_TYPES = ("STRING",)
19
+ FUNCTION = "exec"
20
+ CATEGORY = "utils"
21
+ OUTPUT_NODE = True
22
+
23
+ def exec(self, action, tidy_tags, text_a="", text_b="", text_c=""):
24
+ tidy_tags = tidy_tags == "yes"
25
+ out = ""
26
+ if action == "append":
27
+ out = (", " if tidy_tags else "").join(filter(None, [text_a, text_b, text_c]))
28
+ else:
29
+ if text_c is None:
30
+ text_c = ""
31
+ if text_b.startswith("/") and text_b.endswith("/"):
32
+ regex = text_b[1:-1]
33
+ out = re.sub(regex, text_c, text_a)
34
+ else:
35
+ out = text_a.replace(text_b, text_c)
36
+ if tidy_tags:
37
+ out = re.sub(r"\s{2,}", " ", out)
38
+ out = out.replace(" ,", ",")
39
+ out = re.sub(r",{2,}", ",", out)
40
+ out = out.strip()
41
+ return {"ui": {"text": (out,)}, "result": (out,)}
42
+
43
+ NODE_CLASS_MAPPINGS = {
44
+ "StringFunction|pysssss": StringFunction,
45
+ }
46
+
47
+ NODE_DISPLAY_NAME_MAPPINGS = {
48
+ "StringFunction|pysssss": "String Function 🐍",
49
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/system_notification.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hack: string type that is always equal in not equal comparisons
2
+ class AnyType(str):
3
+ def __ne__(self, __value: object) -> bool:
4
+ return False
5
+
6
+
7
+ # Our any instance wants to be a wildcard string
8
+ any = AnyType("*")
9
+
10
+
11
+ class SystemNotification:
12
+ @classmethod
13
+ def INPUT_TYPES(s):
14
+ return {"required": {
15
+ "message": ("STRING", {"default": "Your notification has triggered."}),
16
+ "any": (any, {}),
17
+ "mode": (["always", "on empty queue"], {}),
18
+ }}
19
+
20
+ FUNCTION = "nop"
21
+ INPUT_IS_LIST = True
22
+ OUTPUT_IS_LIST = (True,)
23
+ OUTPUT_NODE = True
24
+ RETURN_TYPES = (any,)
25
+
26
+ CATEGORY = "utils"
27
+
28
+ def IS_CHANGED(self, **kwargs):
29
+ return float("NaN")
30
+
31
+ def nop(self, any, message, mode):
32
+ return {"ui": {"message": message, "mode": mode}, "result": (any,)}
33
+
34
+
35
+ NODE_CLASS_MAPPINGS = {
36
+ "SystemNotification|pysssss": SystemNotification,
37
+ }
38
+
39
+ NODE_DISPLAY_NAME_MAPPINGS = {
40
+ "SystemNotification|pysssss": "SystemNotification 🐍",
41
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/text_files.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import folder_paths
3
+ import json
4
+ from server import PromptServer
5
+ import glob
6
+ from aiohttp import web
7
+
8
+
9
+ def get_allowed_dirs():
10
+ dir = os.path.abspath(os.path.join(__file__, "../../user"))
11
+ file = os.path.join(dir, "text_file_dirs.json")
12
+ with open(file, "r") as f:
13
+ return json.loads(f.read())
14
+
15
+
16
+ def get_valid_dirs():
17
+ return get_allowed_dirs().keys()
18
+
19
+
20
+ def get_dir_from_name(name):
21
+ dirs = get_allowed_dirs()
22
+ if name not in dirs:
23
+ raise KeyError(name + " dir not found")
24
+
25
+ path = dirs[name]
26
+ path = path.replace("$input", folder_paths.get_input_directory())
27
+ path = path.replace("$output", folder_paths.get_output_directory())
28
+ path = path.replace("$temp", folder_paths.get_temp_directory())
29
+ return path
30
+
31
+
32
+ def is_child_dir(parent_path, child_path):
33
+ parent_path = os.path.abspath(parent_path)
34
+ child_path = os.path.abspath(child_path)
35
+ return os.path.commonpath([parent_path]) == os.path.commonpath([parent_path, child_path])
36
+
37
+
38
+ def get_real_path(dir):
39
+ dir = dir.replace("/**/", "/")
40
+ dir = os.path.abspath(dir)
41
+ dir = os.path.split(dir)[0]
42
+ return dir
43
+
44
+
45
+ @PromptServer.instance.routes.get("/pysssss/text-file/{name}")
46
+ async def get_files(request):
47
+ name = request.match_info["name"]
48
+ dir = get_dir_from_name(name)
49
+ recursive = "/**/" in dir
50
+ # Ugh cant use root_path on glob... lazy hack..
51
+ pre = get_real_path(dir)
52
+
53
+ files = list(map(lambda t: os.path.relpath(t, pre),
54
+ glob.glob(dir, recursive=recursive)))
55
+
56
+ if len(files) == 0:
57
+ files = ["[none]"]
58
+ return web.json_response(files)
59
+
60
+
61
+ def get_file(root_dir, file):
62
+ if file == "[none]" or not file or not file.strip():
63
+ raise ValueError("No file")
64
+
65
+ root_dir = get_dir_from_name(root_dir)
66
+ root_dir = get_real_path(root_dir)
67
+ if not os.path.exists(root_dir):
68
+ os.mkdir(root_dir)
69
+ full_path = os.path.join(root_dir, file)
70
+
71
+ if not is_child_dir(root_dir, full_path):
72
+ raise ReferenceError()
73
+
74
+ return full_path
75
+
76
+
77
+ class TextFileNode:
78
+ RETURN_TYPES = ("STRING",)
79
+ CATEGORY = "utils"
80
+
81
+ @classmethod
82
+ def VALIDATE_INPUTS(self, root_dir, file, **kwargs):
83
+ if file == "[none]" or not file or not file.strip():
84
+ return True
85
+ get_file(root_dir, file)
86
+ return True
87
+
88
+ def load_text(self, **kwargs):
89
+ self.file = get_file(kwargs["root_dir"], kwargs["file"])
90
+ with open(self.file, "r") as f:
91
+ return (f.read(), )
92
+
93
+
94
+ class LoadText(TextFileNode):
95
+ @classmethod
96
+ def IS_CHANGED(self, **kwargs):
97
+ return os.path.getmtime(self.file)
98
+
99
+ @classmethod
100
+ def INPUT_TYPES(s):
101
+ return {
102
+ "required": {
103
+ "root_dir": (list(get_valid_dirs()), {}),
104
+ "file": (["[none]"], {
105
+ "pysssss.binding": [{
106
+ "source": "root_dir",
107
+ "callback": [{
108
+ "type": "set",
109
+ "target": "$this.disabled",
110
+ "value": True
111
+ }, {
112
+ "type": "fetch",
113
+ "url": "/pysssss/text-file/{$source.value}",
114
+ "then": [{
115
+ "type": "set",
116
+ "target": "$this.options.values",
117
+ "value": "$result"
118
+ }, {
119
+ "type": "validate-combo"
120
+ }, {
121
+ "type": "set",
122
+ "target": "$this.disabled",
123
+ "value": False
124
+ }]
125
+ }],
126
+ }]
127
+ })
128
+ },
129
+ }
130
+
131
+ FUNCTION = "load_text"
132
+
133
+
134
+ class SaveText(TextFileNode):
135
+ OUTPUT_NODE = True
136
+
137
+ @classmethod
138
+ def IS_CHANGED(self, **kwargs):
139
+ return float("nan")
140
+
141
+ @classmethod
142
+ def INPUT_TYPES(s):
143
+ return {
144
+ "required": {
145
+ "root_dir": (list(get_valid_dirs()), {}),
146
+ "file": ("STRING", {"default": "file.txt"}),
147
+ "append": (["append", "overwrite", "new only"], {}),
148
+ "insert": ("BOOLEAN", {
149
+ "default": True, "label_on": "new line", "label_off": "none",
150
+ "pysssss.binding": [{
151
+ "source": "append",
152
+ "callback": [{
153
+ "type": "if",
154
+ "condition": [{
155
+ "left": "$source.value",
156
+ "op": "eq",
157
+ "right": '"append"'
158
+ }],
159
+ "true": [{
160
+ "type": "set",
161
+ "target": "$this.disabled",
162
+ "value": False
163
+ }],
164
+ "false": [{
165
+ "type": "set",
166
+ "target": "$this.disabled",
167
+ "value": True
168
+ }],
169
+ }]
170
+ }]
171
+ }),
172
+ "text": ("STRING", {"forceInput": True, "multiline": True})
173
+ },
174
+ }
175
+
176
+ FUNCTION = "write_text"
177
+
178
+ def write_text(self, **kwargs):
179
+ self.file = get_file(kwargs["root_dir"], kwargs["file"])
180
+ if kwargs["append"] == "new only" and os.path.exists(self.file):
181
+ raise FileExistsError(
182
+ self.file + " already exists and 'new only' is selected.")
183
+ with open(self.file, "a+" if kwargs["append"] == "append" else "w") as f:
184
+ is_append = f.tell() != 0
185
+ if is_append and kwargs["insert"]:
186
+ f.write("\n")
187
+ f.write(kwargs["text"])
188
+
189
+ return super().load_text(**kwargs)
190
+
191
+
192
+ NODE_CLASS_MAPPINGS = {
193
+ "LoadText|pysssss": LoadText,
194
+ "SaveText|pysssss": SaveText,
195
+ }
196
+
197
+ NODE_DISPLAY_NAME_MAPPINGS = {
198
+ "LoadText|pysssss": "Load Text 🐍",
199
+ "SaveText|pysssss": "Save Text 🐍",
200
+ }
custom_nodes/ComfyUI-Custom-Scripts/py/workflows.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from server import PromptServer
2
+ from aiohttp import web
3
+ import os
4
+ import inspect
5
+ import json
6
+ import importlib
7
+ import sys
8
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
9
+ import pysssss
10
+
11
+ root_directory = os.path.dirname(inspect.getfile(PromptServer))
12
+ workflows_directory = os.path.join(root_directory, "pysssss-workflows")
13
+ workflows_directory = pysssss.get_config_value(
14
+ "workflows.directory", workflows_directory)
15
+ if not os.path.isabs(workflows_directory):
16
+ workflows_directory = os.path.abspath(os.path.join(root_directory, workflows_directory))
17
+
18
+ NODE_CLASS_MAPPINGS = {}
19
+ NODE_DISPLAY_NAME_MAPPINGS = {}
20
+
21
+
22
+ @PromptServer.instance.routes.get("/pysssss/workflows")
23
+ async def get_workflows(request):
24
+ files = []
25
+ for dirpath, directories, file in os.walk(workflows_directory):
26
+ for file in file:
27
+ if (file.endswith(".json")):
28
+ files.append(os.path.relpath(os.path.join(
29
+ dirpath, file), workflows_directory))
30
+ return web.json_response(list(map(lambda f: os.path.splitext(f)[0].replace("\\", "/"), files)))
31
+
32
+
33
+ @PromptServer.instance.routes.get("/pysssss/workflows/{name:.+}")
34
+ async def get_workflow(request):
35
+ file = os.path.abspath(os.path.join(
36
+ workflows_directory, request.match_info["name"] + ".json"))
37
+ if os.path.commonpath([file, workflows_directory]) != workflows_directory:
38
+ return web.Response(status=403)
39
+
40
+ return web.FileResponse(file)
41
+
42
+
43
+ @PromptServer.instance.routes.post("/pysssss/workflows")
44
+ async def save_workflow(request):
45
+ json_data = await request.json()
46
+ file = os.path.abspath(os.path.join(
47
+ workflows_directory, json_data["name"] + ".json"))
48
+ if os.path.commonpath([file, workflows_directory]) != workflows_directory:
49
+ return web.Response(status=403)
50
+
51
+ if os.path.exists(file) and ("overwrite" not in json_data or json_data["overwrite"] == False):
52
+ return web.Response(status=409)
53
+
54
+ sub_path = os.path.dirname(file)
55
+ if not os.path.exists(sub_path):
56
+ os.makedirs(sub_path)
57
+
58
+ with open(file, "w") as f:
59
+ f.write(json.dumps(json_data["workflow"]))
60
+
61
+ return web.Response(status=201)
custom_nodes/ComfyUI-Custom-Scripts/pyproject.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "comfyui-custom-scripts"
3
+ description = "Enhancements & experiments for ComfyUI, mostly focusing on UI features"
4
+ version = "1.0.0"
5
+ license = { file = "LICENSE" }
6
+
7
+ [project.urls]
8
+ Repository = "https://github.com/pythongosssss/ComfyUI-Custom-Scripts"
9
+
10
+ [tool.comfy]
11
+ PublisherId = "pythongosssss"
12
+ DisplayName = "ComfyUI-Custom-Scripts"
13
+ Icon = ""
custom_nodes/ComfyUI-Custom-Scripts/pysssss.default.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "name": "CustomScripts",
3
+ "logging": false
4
+ }
custom_nodes/ComfyUI-Custom-Scripts/pysssss.example.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "CustomScripts",
3
+ "logging": false,
4
+ "workflows": {
5
+ "directory": "C:\\ComfyUI-Workflows"
6
+ }
7
+ }
custom_nodes/ComfyUI-Custom-Scripts/pysssss.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import json
4
+ import shutil
5
+ import inspect
6
+ import aiohttp
7
+ from server import PromptServer
8
+ from tqdm import tqdm
9
+
10
+ config = None
11
+
12
+
13
+ def is_logging_enabled():
14
+ config = get_extension_config()
15
+ if "logging" not in config:
16
+ return False
17
+ return config["logging"]
18
+
19
+
20
+ def log(message, type=None, always=False, name=None):
21
+ if not always and not is_logging_enabled():
22
+ return
23
+
24
+ if type is not None:
25
+ message = f"[{type}] {message}"
26
+
27
+ if name is None:
28
+ name = get_extension_config()["name"]
29
+
30
+ print(f"(pysssss:{name}) {message}")
31
+
32
+
33
+ def get_ext_dir(subpath=None, mkdir=False):
34
+ dir = os.path.dirname(__file__)
35
+ if subpath is not None:
36
+ dir = os.path.join(dir, subpath)
37
+
38
+ dir = os.path.abspath(dir)
39
+
40
+ if mkdir and not os.path.exists(dir):
41
+ os.makedirs(dir)
42
+ return dir
43
+
44
+
45
+ def get_comfy_dir(subpath=None, mkdir=False):
46
+ dir = os.path.dirname(inspect.getfile(PromptServer))
47
+ if subpath is not None:
48
+ dir = os.path.join(dir, subpath)
49
+
50
+ dir = os.path.abspath(dir)
51
+
52
+ if mkdir and not os.path.exists(dir):
53
+ os.makedirs(dir)
54
+ return dir
55
+
56
+
57
+ def get_web_ext_dir():
58
+ config = get_extension_config()
59
+ name = config["name"]
60
+ dir = get_comfy_dir("web/extensions/pysssss")
61
+ if not os.path.exists(dir):
62
+ os.makedirs(dir)
63
+ dir = os.path.join(dir, name)
64
+ return dir
65
+
66
+
67
+ def get_extension_config(reload=False):
68
+ global config
69
+ if reload == False and config is not None:
70
+ return config
71
+
72
+ config_path = get_ext_dir("pysssss.json")
73
+ default_config_path = get_ext_dir("pysssss.default.json")
74
+ if not os.path.exists(config_path):
75
+ if os.path.exists(default_config_path):
76
+ shutil.copy(default_config_path, config_path)
77
+ if not os.path.exists(config_path):
78
+ log(f"Failed to create config at {config_path}", type="ERROR", always=True, name="???")
79
+ print(f"Extension path: {get_ext_dir()}")
80
+ return {"name": "Unknown", "version": -1}
81
+
82
+ else:
83
+ log("Missing pysssss.default.json, this extension may not work correctly. Please reinstall the extension.",
84
+ type="ERROR", always=True, name="???")
85
+ print(f"Extension path: {get_ext_dir()}")
86
+ return {"name": "Unknown", "version": -1}
87
+
88
+ with open(config_path, "r") as f:
89
+ config = json.loads(f.read())
90
+ return config
91
+
92
+
93
+ def link_js(src, dst):
94
+ src = os.path.abspath(src)
95
+ dst = os.path.abspath(dst)
96
+ if os.name == "nt":
97
+ try:
98
+ import _winapi
99
+ _winapi.CreateJunction(src, dst)
100
+ return True
101
+ except:
102
+ pass
103
+ try:
104
+ os.symlink(src, dst)
105
+ return True
106
+ except:
107
+ import logging
108
+ logging.exception('')
109
+ return False
110
+
111
+
112
+ def is_junction(path):
113
+ if os.name != "nt":
114
+ return False
115
+ try:
116
+ return bool(os.readlink(path))
117
+ except OSError:
118
+ return False
119
+
120
+
121
+ def install_js():
122
+ src_dir = get_ext_dir("web/js")
123
+ if not os.path.exists(src_dir):
124
+ log("No JS")
125
+ return
126
+
127
+ should_install = should_install_js()
128
+ if should_install:
129
+ log("it looks like you're running an old version of ComfyUI that requires manual setup of web files, it is recommended you update your installation.", "warning", True)
130
+ dst_dir = get_web_ext_dir()
131
+ linked = os.path.islink(dst_dir) or is_junction(dst_dir)
132
+ if linked or os.path.exists(dst_dir):
133
+ if linked:
134
+ if should_install:
135
+ log("JS already linked")
136
+ else:
137
+ os.unlink(dst_dir)
138
+ log("JS unlinked, PromptServer will serve extension")
139
+ elif not should_install:
140
+ shutil.rmtree(dst_dir)
141
+ log("JS deleted, PromptServer will serve extension")
142
+ return
143
+
144
+ if not should_install:
145
+ log("JS skipped, PromptServer will serve extension")
146
+ return
147
+
148
+ if link_js(src_dir, dst_dir):
149
+ log("JS linked")
150
+ return
151
+
152
+ log("Copying JS files")
153
+ shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True)
154
+
155
+
156
+ def should_install_js():
157
+ return not hasattr(PromptServer.instance, "supports") or "custom_nodes_from_web" not in PromptServer.instance.supports
158
+
159
+
160
+ def init(check_imports=None):
161
+ log("Init")
162
+
163
+ if check_imports is not None:
164
+ import importlib.util
165
+ for imp in check_imports:
166
+ spec = importlib.util.find_spec(imp)
167
+ if spec is None:
168
+ log(f"{imp} is required, please check requirements are installed.",
169
+ type="ERROR", always=True)
170
+ return False
171
+
172
+ install_js()
173
+ return True
174
+
175
+
176
+ def get_async_loop():
177
+ loop = None
178
+ try:
179
+ loop = asyncio.get_event_loop()
180
+ except:
181
+ loop = asyncio.new_event_loop()
182
+ asyncio.set_event_loop(loop)
183
+ return loop
184
+
185
+
186
+ def get_http_session():
187
+ loop = get_async_loop()
188
+ return aiohttp.ClientSession(loop=loop)
189
+
190
+
191
+ async def download(url, stream, update_callback=None, session=None):
192
+ close_session = False
193
+ if session is None:
194
+ close_session = True
195
+ session = get_http_session()
196
+ try:
197
+ async with session.get(url) as response:
198
+ size = int(response.headers.get('content-length', 0)) or None
199
+
200
+ with tqdm(
201
+ unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1], total=size,
202
+ ) as progressbar:
203
+ perc = 0
204
+ async for chunk in response.content.iter_chunked(2048):
205
+ stream.write(chunk)
206
+ progressbar.update(len(chunk))
207
+ if update_callback is not None and progressbar.total is not None and progressbar.total != 0:
208
+ last = perc
209
+ perc = round(progressbar.n / progressbar.total, 2)
210
+ if perc != last:
211
+ last = perc
212
+ await update_callback(perc)
213
+ finally:
214
+ if close_session and session is not None:
215
+ await session.close()
216
+
217
+
218
+ async def download_to_file(url, destination, update_callback=None, is_ext_subpath=True, session=None):
219
+ if is_ext_subpath:
220
+ destination = get_ext_dir(destination)
221
+ with open(destination, mode='wb') as f:
222
+ download(url, f, update_callback, session)
223
+
224
+
225
+ def wait_for_async(async_fn, loop=None):
226
+ res = []
227
+
228
+ async def run_async():
229
+ r = await async_fn()
230
+ res.append(r)
231
+
232
+ if loop is None:
233
+ try:
234
+ loop = asyncio.get_event_loop()
235
+ except:
236
+ loop = asyncio.new_event_loop()
237
+ asyncio.set_event_loop(loop)
238
+
239
+ loop.run_until_complete(run_async())
240
+
241
+ return res[0]
242
+
243
+
244
+ def update_node_status(client_id, node, text, progress=None):
245
+ if client_id is None:
246
+ client_id = PromptServer.instance.client_id
247
+
248
+ if client_id is None:
249
+ return
250
+
251
+ PromptServer.instance.send_sync("pysssss/update_status", {
252
+ "node": node,
253
+ "progress": progress,
254
+ "text": text
255
+ }, client_id)
256
+
257
+
258
+ async def update_node_status_async(client_id, node, text, progress=None):
259
+ if client_id is None:
260
+ client_id = PromptServer.instance.client_id
261
+
262
+ if client_id is None:
263
+ return
264
+
265
+ await PromptServer.instance.send("pysssss/update_status", {
266
+ "node": node,
267
+ "progress": progress,
268
+ "text": text
269
+ }, client_id)
270
+
271
+
272
+ def get_config_value(key, default=None, throw=False):
273
+ split = key.split(".")
274
+ obj = get_extension_config()
275
+ for s in split:
276
+ if s in obj:
277
+ obj = obj[s]
278
+ else:
279
+ if throw:
280
+ raise KeyError("Configuration key missing: " + key)
281
+ else:
282
+ return default
283
+ return obj
284
+
285
+
286
+ def is_inside_dir(root_dir, check_path):
287
+ root_dir = os.path.abspath(root_dir)
288
+ if not os.path.isabs(check_path):
289
+ check_path = os.path.abspath(os.path.join(root_dir, check_path))
290
+ return os.path.commonpath([check_path, root_dir]) == root_dir
291
+
292
+
293
+ def get_child_dir(root_dir, child_path, throw_if_outside=True):
294
+ child_path = os.path.abspath(os.path.join(root_dir, child_path))
295
+ if is_inside_dir(root_dir, child_path):
296
+ return child_path
297
+ if throw_if_outside:
298
+ raise NotADirectoryError(
299
+ "Saving outside the target folder is not allowed.")
300
+ return None
custom_nodes/ComfyUI-Custom-Scripts/user/text_file_dirs.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "input": "$input/**/*.txt",
3
+ "output": "$output/**/*.txt",
4
+ "temp": "$temp/**/*.txt"
5
+ }
custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/canvas2svg.js ADDED
@@ -0,0 +1,1192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*!!
2
+ * Canvas 2 Svg v1.0.19
3
+ * A low level canvas to SVG converter. Uses a mock canvas context to build an SVG document.
4
+ *
5
+ * Licensed under the MIT license:
6
+ * http://www.opensource.org/licenses/mit-license.php
7
+ *
8
+ * Author:
9
+ * Kerry Liu
10
+ *
11
+ * Copyright (c) 2014 Gliffy Inc.
12
+ */
13
+
14
+ ;(function() {
15
+ "use strict";
16
+
17
+ var STYLES, ctx, CanvasGradient, CanvasPattern, namedEntities;
18
+
19
+ //helper function to format a string
20
+ function format(str, args) {
21
+ var keys = Object.keys(args), i;
22
+ for (i=0; i<keys.length; i++) {
23
+ str = str.replace(new RegExp("\\{" + keys[i] + "\\}", "gi"), args[keys[i]]);
24
+ }
25
+ return str;
26
+ }
27
+
28
+ //helper function that generates a random string
29
+ function randomString(holder) {
30
+ var chars, randomstring, i;
31
+ if (!holder) {
32
+ throw new Error("cannot create a random attribute name for an undefined object");
33
+ }
34
+ chars = "ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz";
35
+ randomstring = "";
36
+ do {
37
+ randomstring = "";
38
+ for (i = 0; i < 12; i++) {
39
+ randomstring += chars[Math.floor(Math.random() * chars.length)];
40
+ }
41
+ } while (holder[randomstring]);
42
+ return randomstring;
43
+ }
44
+
45
+ //helper function to map named to numbered entities
46
+ function createNamedToNumberedLookup(items, radix) {
47
+ var i, entity, lookup = {}, base10, base16;
48
+ items = items.split(',');
49
+ radix = radix || 10;
50
+ // Map from named to numbered entities.
51
+ for (i = 0; i < items.length; i += 2) {
52
+ entity = '&' + items[i + 1] + ';';
53
+ base10 = parseInt(items[i], radix);
54
+ lookup[entity] = '&#'+base10+';';
55
+ }
56
+ //FF and IE need to create a regex from hex values ie &nbsp; == \xa0
57
+ lookup["\\xa0"] = '&#160;';
58
+ return lookup;
59
+ }
60
+
61
+ //helper function to map canvas-textAlign to svg-textAnchor
62
+ function getTextAnchor(textAlign) {
63
+ //TODO: support rtl languages
64
+ var mapping = {"left":"start", "right":"end", "center":"middle", "start":"start", "end":"end"};
65
+ return mapping[textAlign] || mapping.start;
66
+ }
67
+
68
+ //helper function to map canvas-textBaseline to svg-dominantBaseline
69
+ function getDominantBaseline(textBaseline) {
70
+ //INFO: not supported in all browsers
71
+ var mapping = {"alphabetic": "alphabetic", "hanging": "hanging", "top":"text-before-edge", "bottom":"text-after-edge", "middle":"central"};
72
+ return mapping[textBaseline] || mapping.alphabetic;
73
+ }
74
+
75
+ // Unpack entities lookup where the numbers are in radix 32 to reduce the size
76
+ // entity mapping courtesy of tinymce
77
+ namedEntities = createNamedToNumberedLookup(
78
+ '50,nbsp,51,iexcl,52,cent,53,pound,54,curren,55,yen,56,brvbar,57,sect,58,uml,59,copy,' +
79
+ '5a,ordf,5b,laquo,5c,not,5d,shy,5e,reg,5f,macr,5g,deg,5h,plusmn,5i,sup2,5j,sup3,5k,acute,' +
80
+ '5l,micro,5m,para,5n,middot,5o,cedil,5p,sup1,5q,ordm,5r,raquo,5s,frac14,5t,frac12,5u,frac34,' +
81
+ '5v,iquest,60,Agrave,61,Aacute,62,Acirc,63,Atilde,64,Auml,65,Aring,66,AElig,67,Ccedil,' +
82
+ '68,Egrave,69,Eacute,6a,Ecirc,6b,Euml,6c,Igrave,6d,Iacute,6e,Icirc,6f,Iuml,6g,ETH,6h,Ntilde,' +
83
+ '6i,Ograve,6j,Oacute,6k,Ocirc,6l,Otilde,6m,Ouml,6n,times,6o,Oslash,6p,Ugrave,6q,Uacute,' +
84
+ '6r,Ucirc,6s,Uuml,6t,Yacute,6u,THORN,6v,szlig,70,agrave,71,aacute,72,acirc,73,atilde,74,auml,' +
85
+ '75,aring,76,aelig,77,ccedil,78,egrave,79,eacute,7a,ecirc,7b,euml,7c,igrave,7d,iacute,7e,icirc,' +
86
+ '7f,iuml,7g,eth,7h,ntilde,7i,ograve,7j,oacute,7k,ocirc,7l,otilde,7m,ouml,7n,divide,7o,oslash,' +
87
+ '7p,ugrave,7q,uacute,7r,ucirc,7s,uuml,7t,yacute,7u,thorn,7v,yuml,ci,fnof,sh,Alpha,si,Beta,' +
88
+ 'sj,Gamma,sk,Delta,sl,Epsilon,sm,Zeta,sn,Eta,so,Theta,sp,Iota,sq,Kappa,sr,Lambda,ss,Mu,' +
89
+ 'st,Nu,su,Xi,sv,Omicron,t0,Pi,t1,Rho,t3,Sigma,t4,Tau,t5,Upsilon,t6,Phi,t7,Chi,t8,Psi,' +
90
+ 't9,Omega,th,alpha,ti,beta,tj,gamma,tk,delta,tl,epsilon,tm,zeta,tn,eta,to,theta,tp,iota,' +
91
+ 'tq,kappa,tr,lambda,ts,mu,tt,nu,tu,xi,tv,omicron,u0,pi,u1,rho,u2,sigmaf,u3,sigma,u4,tau,' +
92
+ 'u5,upsilon,u6,phi,u7,chi,u8,psi,u9,omega,uh,thetasym,ui,upsih,um,piv,812,bull,816,hellip,' +
93
+ '81i,prime,81j,Prime,81u,oline,824,frasl,88o,weierp,88h,image,88s,real,892,trade,89l,alefsym,' +
94
+ '8cg,larr,8ch,uarr,8ci,rarr,8cj,darr,8ck,harr,8dl,crarr,8eg,lArr,8eh,uArr,8ei,rArr,8ej,dArr,' +
95
+ '8ek,hArr,8g0,forall,8g2,part,8g3,exist,8g5,empty,8g7,nabla,8g8,isin,8g9,notin,8gb,ni,8gf,prod,' +
96
+ '8gh,sum,8gi,minus,8gn,lowast,8gq,radic,8gt,prop,8gu,infin,8h0,ang,8h7,and,8h8,or,8h9,cap,8ha,cup,' +
97
+ '8hb,int,8hk,there4,8hs,sim,8i5,cong,8i8,asymp,8j0,ne,8j1,equiv,8j4,le,8j5,ge,8k2,sub,8k3,sup,8k4,' +
98
+ 'nsub,8k6,sube,8k7,supe,8kl,oplus,8kn,otimes,8l5,perp,8m5,sdot,8o8,lceil,8o9,rceil,8oa,lfloor,8ob,' +
99
+ 'rfloor,8p9,lang,8pa,rang,9ea,loz,9j0,spades,9j3,clubs,9j5,hearts,9j6,diams,ai,OElig,aj,oelig,b0,' +
100
+ 'Scaron,b1,scaron,bo,Yuml,m6,circ,ms,tilde,802,ensp,803,emsp,809,thinsp,80c,zwnj,80d,zwj,80e,lrm,' +
101
+ '80f,rlm,80j,ndash,80k,mdash,80o,lsquo,80p,rsquo,80q,sbquo,80s,ldquo,80t,rdquo,80u,bdquo,810,dagger,' +
102
+ '811,Dagger,81g,permil,81p,lsaquo,81q,rsaquo,85c,euro', 32);
103
+
104
+
105
+ //Some basic mappings for attributes and default values.
106
+ STYLES = {
107
+ "strokeStyle":{
108
+ svgAttr : "stroke", //corresponding svg attribute
109
+ canvas : "#000000", //canvas default
110
+ svg : "none", //svg default
111
+ apply : "stroke" //apply on stroke() or fill()
112
+ },
113
+ "fillStyle":{
114
+ svgAttr : "fill",
115
+ canvas : "#000000",
116
+ svg : null, //svg default is black, but we need to special case this to handle canvas stroke without fill
117
+ apply : "fill"
118
+ },
119
+ "lineCap":{
120
+ svgAttr : "stroke-linecap",
121
+ canvas : "butt",
122
+ svg : "butt",
123
+ apply : "stroke"
124
+ },
125
+ "lineJoin":{
126
+ svgAttr : "stroke-linejoin",
127
+ canvas : "miter",
128
+ svg : "miter",
129
+ apply : "stroke"
130
+ },
131
+ "miterLimit":{
132
+ svgAttr : "stroke-miterlimit",
133
+ canvas : 10,
134
+ svg : 4,
135
+ apply : "stroke"
136
+ },
137
+ "lineWidth":{
138
+ svgAttr : "stroke-width",
139
+ canvas : 1,
140
+ svg : 1,
141
+ apply : "stroke"
142
+ },
143
+ "globalAlpha": {
144
+ svgAttr : "opacity",
145
+ canvas : 1,
146
+ svg : 1,
147
+ apply : "fill stroke"
148
+ },
149
+ "font":{
150
+ //font converts to multiple svg attributes, there is custom logic for this
151
+ canvas : "10px sans-serif"
152
+ },
153
+ "shadowColor":{
154
+ canvas : "#000000"
155
+ },
156
+ "shadowOffsetX":{
157
+ canvas : 0
158
+ },
159
+ "shadowOffsetY":{
160
+ canvas : 0
161
+ },
162
+ "shadowBlur":{
163
+ canvas : 0
164
+ },
165
+ "textAlign":{
166
+ canvas : "start"
167
+ },
168
+ "textBaseline":{
169
+ canvas : "alphabetic"
170
+ },
171
+ "lineDash" : {
172
+ svgAttr : "stroke-dasharray",
173
+ canvas : [],
174
+ svg : null,
175
+ apply : "stroke"
176
+ }
177
+ };
178
+
179
+ /**
180
+ *
181
+ * @param gradientNode - reference to the gradient
182
+ * @constructor
183
+ */
184
+ CanvasGradient = function(gradientNode, ctx) {
185
+ this.__root = gradientNode;
186
+ this.__ctx = ctx;
187
+ };
188
+
189
+ /**
190
+ * Adds a color stop to the gradient root
191
+ */
192
+ CanvasGradient.prototype.addColorStop = function(offset, color) {
193
+ var stop = this.__ctx.__createElement("stop"), regex, matches;
194
+ stop.setAttribute("offset", offset);
195
+ if(color.indexOf("rgba") !== -1) {
196
+ //separate alpha value, since webkit can't handle it
197
+ regex = /rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d?\.?\d*)\s*\)/gi;
198
+ matches = regex.exec(color);
199
+ stop.setAttribute("stop-color", format("rgb({r},{g},{b})", {r:matches[1], g:matches[2], b:matches[3]}));
200
+ stop.setAttribute("stop-opacity", matches[4]);
201
+ } else {
202
+ stop.setAttribute("stop-color", color);
203
+ }
204
+ this.__root.appendChild(stop);
205
+ };
206
+
207
+ CanvasPattern = function(pattern, ctx) {
208
+ this.__root = pattern;
209
+ this.__ctx = ctx;
210
+ };
211
+
212
+ /**
213
+ * The mock canvas context
214
+ * @param o - options include:
215
+ * width - width of your canvas (defaults to 500)
216
+ * height - height of your canvas (defaults to 500)
217
+ * enableMirroring - enables canvas mirroring (get image data) (defaults to false)
218
+ * document - the document object (defaults to the current document)
219
+ */
220
+ ctx = function(o) {
221
+
222
+ var defaultOptions = { width:500, height:500, enableMirroring : false}, options;
223
+
224
+ //keep support for this way of calling C2S: new C2S(width,height)
225
+ if(arguments.length > 1) {
226
+ options = defaultOptions;
227
+ options.width = arguments[0];
228
+ options.height = arguments[1];
229
+ } else if( !o ) {
230
+ options = defaultOptions;
231
+ } else {
232
+ options = o;
233
+ }
234
+
235
+ if(!(this instanceof ctx)) {
236
+ //did someone call this without new?
237
+ return new ctx(options);
238
+ }
239
+
240
+ //setup options
241
+ this.width = options.width || defaultOptions.width;
242
+ this.height = options.height || defaultOptions.height;
243
+ this.enableMirroring = options.enableMirroring !== undefined ? options.enableMirroring : defaultOptions.enableMirroring;
244
+
245
+ this.canvas = this; ///point back to this instance!
246
+ this.__document = options.document || document;
247
+ this.__canvas = this.__document.createElement("canvas");
248
+ this.__ctx = this.__canvas.getContext("2d");
249
+
250
+ this.__setDefaultStyles();
251
+ this.__stack = [this.__getStyleState()];
252
+ this.__groupStack = [];
253
+
254
+ //the root svg element
255
+ this.__root = this.__document.createElementNS("http://www.w3.org/2000/svg", "svg");
256
+ this.__root.setAttribute("version", 1.1);
257
+ this.__root.setAttribute("xmlns", "http://www.w3.org/2000/svg");
258
+ this.__root.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink");
259
+ this.__root.setAttribute("width", this.width);
260
+ this.__root.setAttribute("height", this.height);
261
+
262
+ //make sure we don't generate the same ids in defs
263
+ this.__ids = {};
264
+
265
+ //defs tag
266
+ this.__defs = this.__document.createElementNS("http://www.w3.org/2000/svg", "defs");
267
+ this.__root.appendChild(this.__defs);
268
+
269
+ //also add a group child. the svg element can't use the transform attribute
270
+ this.__currentElement = this.__document.createElementNS("http://www.w3.org/2000/svg", "g");
271
+ this.__root.appendChild(this.__currentElement);
272
+ };
273
+
274
+
275
+ /**
276
+ * Creates the specified svg element
277
+ * @private
278
+ */
279
+ ctx.prototype.__createElement = function (elementName, properties, resetFill) {
280
+ if (typeof properties === "undefined") {
281
+ properties = {};
282
+ }
283
+
284
+ var element = this.__document.createElementNS("http://www.w3.org/2000/svg", elementName),
285
+ keys = Object.keys(properties), i, key;
286
+ if(resetFill) {
287
+ //if fill or stroke is not specified, the svg element should not display. By default SVG's fill is black.
288
+ element.setAttribute("fill", "none");
289
+ element.setAttribute("stroke", "none");
290
+ }
291
+ for(i=0; i<keys.length; i++) {
292
+ key = keys[i];
293
+ element.setAttribute(key, properties[key]);
294
+ }
295
+ return element;
296
+ };
297
+
298
+ /**
299
+ * Applies default canvas styles to the context
300
+ * @private
301
+ */
302
+ ctx.prototype.__setDefaultStyles = function() {
303
+ //default 2d canvas context properties see:http://www.w3.org/TR/2dcontext/
304
+ var keys = Object.keys(STYLES), i, key;
305
+ for(i=0; i<keys.length; i++) {
306
+ key = keys[i];
307
+ this[key] = STYLES[key].canvas;
308
+ }
309
+ };
310
+
311
+ /**
312
+ * Applies styles on restore
313
+ * @param styleState
314
+ * @private
315
+ */
316
+ ctx.prototype.__applyStyleState = function(styleState) {
317
+ var keys = Object.keys(styleState), i, key;
318
+ for(i=0; i<keys.length; i++) {
319
+ key = keys[i];
320
+ this[key] = styleState[key];
321
+ }
322
+ };
323
+
324
+ /**
325
+ * Gets the current style state
326
+ * @return {Object}
327
+ * @private
328
+ */
329
+ ctx.prototype.__getStyleState = function() {
330
+ var i, styleState = {}, keys = Object.keys(STYLES), key;
331
+ for(i=0; i<keys.length; i++) {
332
+ key = keys[i];
333
+ styleState[key] = this[key];
334
+ }
335
+ return styleState;
336
+ };
337
+
338
+ /**
339
+ * Apples the current styles to the current SVG element. On "ctx.fill" or "ctx.stroke"
340
+ * @param type
341
+ * @private
342
+ */
343
+ ctx.prototype.__applyStyleToCurrentElement = function(type) {
344
+ var keys = Object.keys(STYLES), i, style, value, id, regex, matches;
345
+ for(i=0; i<keys.length; i++) {
346
+ style = STYLES[keys[i]];
347
+ value = this[keys[i]];
348
+ if(style.apply) {
349
+ //is this a gradient or pattern?
350
+ if(style.apply.indexOf("fill")!==-1 && value instanceof CanvasPattern) {
351
+ //pattern
352
+ if(value.__ctx) {
353
+ //copy over defs
354
+ while(value.__ctx.__defs.childNodes.length) {
355
+ id = value.__ctx.__defs.childNodes[0].getAttribute("id");
356
+ this.__ids[id] = id;
357
+ this.__defs.appendChild(value.__ctx.__defs.childNodes[0]);
358
+ }
359
+ }
360
+ this.__currentElement.setAttribute("fill", format("url(#{id})", {id:value.__root.getAttribute("id")}));
361
+ }
362
+ else if(style.apply.indexOf("fill")!==-1 && value instanceof CanvasGradient) {
363
+ //gradient
364
+ this.__currentElement.setAttribute("fill", format("url(#{id})", {id:value.__root.getAttribute("id")}));
365
+ } else if(style.apply.indexOf(type)!==-1 && style.svg !== value) {
366
+ if((style.svgAttr === "stroke" || style.svgAttr === "fill") && value.indexOf("rgba") !== -1) {
367
+ //separate alpha value, since illustrator can't handle it
368
+ regex = /rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d?\.?\d*)\s*\)/gi;
369
+ matches = regex.exec(value);
370
+ this.__currentElement.setAttribute(style.svgAttr, format("rgb({r},{g},{b})", {r:matches[1], g:matches[2], b:matches[3]}));
371
+ //should take globalAlpha here
372
+ var opacity = matches[4];
373
+ var globalAlpha = this.globalAlpha;
374
+ if (globalAlpha != null) {
375
+ opacity *= globalAlpha;
376
+ }
377
+ this.__currentElement.setAttribute(style.svgAttr+"-opacity", opacity);
378
+ } else {
379
+ var attr = style.svgAttr;
380
+ if (keys[i] === 'globalAlpha') {
381
+ attr = type+'-'+style.svgAttr;
382
+ if (this.__currentElement.getAttribute(attr)) {
383
+ //fill-opacity or stroke-opacity has already been set by stroke or fill.
384
+ continue;
385
+ }
386
+ }
387
+ //otherwise only update attribute if right type, and not svg default
388
+ this.__currentElement.setAttribute(attr, value);
389
+
390
+
391
+ }
392
+ }
393
+ }
394
+ }
395
+
396
+ };
397
+
398
+ /**
399
+ * Will return the closest group or svg node. May return the current element.
400
+ * @private
401
+ */
402
+ ctx.prototype.__closestGroupOrSvg = function(node) {
403
+ node = node || this.__currentElement;
404
+ if(node.nodeName === "g" || node.nodeName === "svg") {
405
+ return node;
406
+ } else {
407
+ return this.__closestGroupOrSvg(node.parentNode);
408
+ }
409
+ };
410
+
411
+ /**
412
+ * Returns the serialized value of the svg so far
413
+ * @param fixNamedEntities - Standalone SVG doesn't support named entities, which document.createTextNode encodes.
414
+ * If true, we attempt to find all named entities and encode it as a numeric entity.
415
+ * @return serialized svg
416
+ */
417
+ ctx.prototype.getSerializedSvg = function(fixNamedEntities) {
418
+ var serialized = new XMLSerializer().serializeToString(this.__root),
419
+ keys, i, key, value, regexp, xmlns;
420
+
421
+ //IE search for a duplicate xmnls because they didn't implement setAttributeNS correctly
422
+ xmlns = /xmlns="http:\/\/www\.w3\.org\/2000\/svg".+xmlns="http:\/\/www\.w3\.org\/2000\/svg/gi;
423
+ if(xmlns.test(serialized)) {
424
+ serialized = serialized.replace('xmlns="http://www.w3.org/2000/svg','xmlns:xlink="http://www.w3.org/1999/xlink');
425
+ }
426
+
427
+ if(fixNamedEntities) {
428
+ keys = Object.keys(namedEntities);
429
+ //loop over each named entity and replace with the proper equivalent.
430
+ for(i=0; i<keys.length; i++) {
431
+ key = keys[i];
432
+ value = namedEntities[key];
433
+ regexp = new RegExp(key, "gi");
434
+ if(regexp.test(serialized)) {
435
+ serialized = serialized.replace(regexp, value);
436
+ }
437
+ }
438
+ }
439
+
440
+ return serialized;
441
+ };
442
+
443
+
444
+ /**
445
+ * Returns the root svg
446
+ * @return
447
+ */
448
+ ctx.prototype.getSvg = function() {
449
+ return this.__root;
450
+ };
451
+ /**
452
+ * Will generate a group tag.
453
+ */
454
+ ctx.prototype.save = function() {
455
+ var group = this.__createElement("g"), parent = this.__closestGroupOrSvg();
456
+ this.__groupStack.push(parent);
457
+ parent.appendChild(group);
458
+ this.__currentElement = group;
459
+ this.__stack.push(this.__getStyleState());
460
+ };
461
+ /**
462
+ * Sets current element to parent, or just root if already root
463
+ */
464
+ ctx.prototype.restore = function(){
465
+ this.__currentElement = this.__groupStack.pop();
466
+ //Clearing canvas will make the poped group invalid, currentElement is set to the root group node.
467
+ if (!this.__currentElement) {
468
+ this.__currentElement = this.__root.childNodes[1];
469
+ }
470
+ var state = this.__stack.pop();
471
+ this.__applyStyleState(state);
472
+
473
+ };
474
+
475
+ /**
476
+ * Helper method to add transform
477
+ * @private
478
+ */
479
+ ctx.prototype.__addTransform = function(t) {
480
+
481
+ //if the current element has siblings, add another group
482
+ var parent = this.__closestGroupOrSvg();
483
+ if(parent.childNodes.length > 0) {
484
+ var group = this.__createElement("g");
485
+ parent.appendChild(group);
486
+ this.__currentElement = group;
487
+ }
488
+
489
+ var transform = this.__currentElement.getAttribute("transform");
490
+ if(transform) {
491
+ transform += " ";
492
+ } else {
493
+ transform = "";
494
+ }
495
+ transform += t;
496
+ this.__currentElement.setAttribute("transform", transform);
497
+ };
498
+
499
+ /**
500
+ * scales the current element
501
+ */
502
+ ctx.prototype.scale = function(x, y) {
503
+ if(y === undefined) {
504
+ y = x;
505
+ }
506
+ this.__addTransform(format("scale({x},{y})", {x:x, y:y}));
507
+ };
508
+
509
+ /**
510
+ * rotates the current element
511
+ */
512
+ ctx.prototype.rotate = function(angle){
513
+ var degrees = (angle * 180 / Math.PI);
514
+ this.__addTransform(format("rotate({angle},{cx},{cy})", {angle:degrees, cx:0, cy:0}));
515
+ };
516
+
517
+ /**
518
+ * translates the current element
519
+ */
520
+ ctx.prototype.translate = function(x, y){
521
+ this.__addTransform(format("translate({x},{y})", {x:x,y:y}));
522
+ };
523
+
524
+ /**
525
+ * applies a transform to the current element
526
+ */
527
+ ctx.prototype.transform = function(a, b, c, d, e, f){
528
+ this.__addTransform(format("matrix({a},{b},{c},{d},{e},{f})", {a:a, b:b, c:c, d:d, e:e, f:f}));
529
+ };
530
+
531
+ /**
532
+ * Create a new Path Element
533
+ */
534
+ ctx.prototype.beginPath = function(){
535
+ var path, parent;
536
+
537
+ // Note that there is only one current default path, it is not part of the drawing state.
538
+ // See also: https://html.spec.whatwg.org/multipage/scripting.html#current-default-path
539
+ this.__currentDefaultPath = "";
540
+ this.__currentPosition = {};
541
+
542
+ path = this.__createElement("path", {}, true);
543
+ parent = this.__closestGroupOrSvg();
544
+ parent.appendChild(path);
545
+ this.__currentElement = path;
546
+ };
547
+
548
+ /**
549
+ * Helper function to apply currentDefaultPath to current path element
550
+ * @private
551
+ */
552
+ ctx.prototype.__applyCurrentDefaultPath = function() {
553
+ if(this.__currentElement.nodeName === "path") {
554
+ var d = this.__currentDefaultPath;
555
+ this.__currentElement.setAttribute("d", d);
556
+ } else {
557
+ throw new Error("Attempted to apply path command to node " + this.__currentElement.nodeName);
558
+ }
559
+ };
560
+
561
+ /**
562
+ * Helper function to add path command
563
+ * @private
564
+ */
565
+ ctx.prototype.__addPathCommand = function(command){
566
+ this.__currentDefaultPath += " ";
567
+ this.__currentDefaultPath += command;
568
+ };
569
+
570
+ /**
571
+ * Adds the move command to the current path element,
572
+ * if the currentPathElement is not empty create a new path element
573
+ */
574
+ ctx.prototype.moveTo = function(x,y){
575
+ if(this.__currentElement.nodeName !== "path") {
576
+ this.beginPath();
577
+ }
578
+
579
+ // creates a new subpath with the given point
580
+ this.__currentPosition = {x: x, y: y};
581
+ this.__addPathCommand(format("M {x} {y}", {x:x, y:y}));
582
+ };
583
+
584
+ /**
585
+ * Closes the current path
586
+ */
587
+ ctx.prototype.closePath = function(){
588
+ this.__addPathCommand("Z");
589
+ };
590
+
591
+ /**
592
+ * Adds a line to command
593
+ */
594
+ ctx.prototype.lineTo = function(x, y){
595
+ this.__currentPosition = {x: x, y: y};
596
+ if (this.__currentDefaultPath.indexOf('M') > -1) {
597
+ this.__addPathCommand(format("L {x} {y}", {x:x, y:y}));
598
+ } else {
599
+ this.__addPathCommand(format("M {x} {y}", {x:x, y:y}));
600
+ }
601
+ };
602
+
603
+ /**
604
+ * Add a bezier command
605
+ */
606
+ ctx.prototype.bezierCurveTo = function(cp1x, cp1y, cp2x, cp2y, x, y) {
607
+ this.__currentPosition = {x: x, y: y};
608
+ this.__addPathCommand(format("C {cp1x} {cp1y} {cp2x} {cp2y} {x} {y}",
609
+ {cp1x:cp1x, cp1y:cp1y, cp2x:cp2x, cp2y:cp2y, x:x, y:y}));
610
+ };
611
+
612
+ /**
613
+ * Adds a quadratic curve to command
614
+ */
615
+ ctx.prototype.quadraticCurveTo = function(cpx, cpy, x, y){
616
+ this.__currentPosition = {x: x, y: y};
617
+ this.__addPathCommand(format("Q {cpx} {cpy} {x} {y}", {cpx:cpx, cpy:cpy, x:x, y:y}));
618
+ };
619
+
620
+
621
+ /**
622
+ * Return a new normalized vector of given vector
623
+ */
624
+ var normalize = function(vector) {
625
+ var len = Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1]);
626
+ return [vector[0] / len, vector[1] / len];
627
+ };
628
+
629
+ /**
630
+ * Adds the arcTo to the current path
631
+ *
632
+ * @see http://www.w3.org/TR/2015/WD-2dcontext-20150514/#dom-context-2d-arcto
633
+ */
634
+ ctx.prototype.arcTo = function(x1, y1, x2, y2, radius) {
635
+ // Let the point (x0, y0) be the last point in the subpath.
636
+ var x0 = this.__currentPosition && this.__currentPosition.x;
637
+ var y0 = this.__currentPosition && this.__currentPosition.y;
638
+
639
+ // First ensure there is a subpath for (x1, y1).
640
+ if (typeof x0 == "undefined" || typeof y0 == "undefined") {
641
+ return;
642
+ }
643
+
644
+ // Negative values for radius must cause the implementation to throw an IndexSizeError exception.
645
+ if (radius < 0) {
646
+ throw new Error("IndexSizeError: The radius provided (" + radius + ") is negative.");
647
+ }
648
+
649
+ // If the point (x0, y0) is equal to the point (x1, y1),
650
+ // or if the point (x1, y1) is equal to the point (x2, y2),
651
+ // or if the radius radius is zero,
652
+ // then the method must add the point (x1, y1) to the subpath,
653
+ // and connect that point to the previous point (x0, y0) by a straight line.
654
+ if (((x0 === x1) && (y0 === y1))
655
+ || ((x1 === x2) && (y1 === y2))
656
+ || (radius === 0)) {
657
+ this.lineTo(x1, y1);
658
+ return;
659
+ }
660
+
661
+ // Otherwise, if the points (x0, y0), (x1, y1), and (x2, y2) all lie on a single straight line,
662
+ // then the method must add the point (x1, y1) to the subpath,
663
+ // and connect that point to the previous point (x0, y0) by a straight line.
664
+ var unit_vec_p1_p0 = normalize([x0 - x1, y0 - y1]);
665
+ var unit_vec_p1_p2 = normalize([x2 - x1, y2 - y1]);
666
+ if (unit_vec_p1_p0[0] * unit_vec_p1_p2[1] === unit_vec_p1_p0[1] * unit_vec_p1_p2[0]) {
667
+ this.lineTo(x1, y1);
668
+ return;
669
+ }
670
+
671
+ // Otherwise, let The Arc be the shortest arc given by circumference of the circle that has radius radius,
672
+ // and that has one point tangent to the half-infinite line that crosses the point (x0, y0) and ends at the point (x1, y1),
673
+ // and that has a different point tangent to the half-infinite line that ends at the point (x1, y1), and crosses the point (x2, y2).
674
+ // The points at which this circle touches these two lines are called the start and end tangent points respectively.
675
+
676
+ // note that both vectors are unit vectors, so the length is 1
677
+ var cos = (unit_vec_p1_p0[0] * unit_vec_p1_p2[0] + unit_vec_p1_p0[1] * unit_vec_p1_p2[1]);
678
+ var theta = Math.acos(Math.abs(cos));
679
+
680
+ // Calculate origin
681
+ var unit_vec_p1_origin = normalize([
682
+ unit_vec_p1_p0[0] + unit_vec_p1_p2[0],
683
+ unit_vec_p1_p0[1] + unit_vec_p1_p2[1]
684
+ ]);
685
+ var len_p1_origin = radius / Math.sin(theta / 2);
686
+ var x = x1 + len_p1_origin * unit_vec_p1_origin[0];
687
+ var y = y1 + len_p1_origin * unit_vec_p1_origin[1];
688
+
689
+ // Calculate start angle and end angle
690
+ // rotate 90deg clockwise (note that y axis points to its down)
691
+ var unit_vec_origin_start_tangent = [
692
+ -unit_vec_p1_p0[1],
693
+ unit_vec_p1_p0[0]
694
+ ];
695
+ // rotate 90deg counter clockwise (note that y axis points to its down)
696
+ var unit_vec_origin_end_tangent = [
697
+ unit_vec_p1_p2[1],
698
+ -unit_vec_p1_p2[0]
699
+ ];
700
+ var getAngle = function(vector) {
701
+ // get angle (clockwise) between vector and (1, 0)
702
+ var x = vector[0];
703
+ var y = vector[1];
704
+ if (y >= 0) { // note that y axis points to its down
705
+ return Math.acos(x);
706
+ } else {
707
+ return -Math.acos(x);
708
+ }
709
+ };
710
+ var startAngle = getAngle(unit_vec_origin_start_tangent);
711
+ var endAngle = getAngle(unit_vec_origin_end_tangent);
712
+
713
+ // Connect the point (x0, y0) to the start tangent point by a straight line
714
+ this.lineTo(x + unit_vec_origin_start_tangent[0] * radius,
715
+ y + unit_vec_origin_start_tangent[1] * radius);
716
+
717
+ // Connect the start tangent point to the end tangent point by arc
718
+ // and adding the end tangent point to the subpath.
719
+ this.arc(x, y, radius, startAngle, endAngle);
720
+ };
721
+
722
+ /**
723
+ * Sets the stroke property on the current element
724
+ */
725
+ ctx.prototype.stroke = function(){
726
+ if(this.__currentElement.nodeName === "path") {
727
+ this.__currentElement.setAttribute("paint-order", "fill stroke markers");
728
+ }
729
+ this.__applyCurrentDefaultPath();
730
+ this.__applyStyleToCurrentElement("stroke");
731
+ };
732
+
733
+ /**
734
+ * Sets fill properties on the current element
735
+ */
736
+ ctx.prototype.fill = function(){
737
+ if(this.__currentElement.nodeName === "path") {
738
+ this.__currentElement.setAttribute("paint-order", "stroke fill markers");
739
+ }
740
+ this.__applyCurrentDefaultPath();
741
+ this.__applyStyleToCurrentElement("fill");
742
+ };
743
+
744
+ /**
745
+ * Adds a rectangle to the path.
746
+ */
747
+ ctx.prototype.rect = function(x, y, width, height){
748
+ if(this.__currentElement.nodeName !== "path") {
749
+ this.beginPath();
750
+ }
751
+ this.moveTo(x, y);
752
+ this.lineTo(x+width, y);
753
+ this.lineTo(x+width, y+height);
754
+ this.lineTo(x, y+height);
755
+ this.lineTo(x, y);
756
+ this.closePath();
757
+ };
758
+
759
+
760
+ /**
761
+ * adds a rectangle element
762
+ */
763
+ ctx.prototype.fillRect = function(x, y, width, height){
764
+ var rect, parent;
765
+ rect = this.__createElement("rect", {
766
+ x : x,
767
+ y : y,
768
+ width : width,
769
+ height : height
770
+ }, true);
771
+ parent = this.__closestGroupOrSvg();
772
+ parent.appendChild(rect);
773
+ this.__currentElement = rect;
774
+ this.__applyStyleToCurrentElement("fill");
775
+ };
776
+
777
+ /**
778
+ * Draws a rectangle with no fill
779
+ * @param x
780
+ * @param y
781
+ * @param width
782
+ * @param height
783
+ */
784
+ ctx.prototype.strokeRect = function(x, y, width, height){
785
+ var rect, parent;
786
+ rect = this.__createElement("rect", {
787
+ x : x,
788
+ y : y,
789
+ width : width,
790
+ height : height
791
+ }, true);
792
+ parent = this.__closestGroupOrSvg();
793
+ parent.appendChild(rect);
794
+ this.__currentElement = rect;
795
+ this.__applyStyleToCurrentElement("stroke");
796
+ };
797
+
798
+
799
+ /**
800
+ * Clear entire canvas:
801
+ * 1. save current transforms
802
+ * 2. remove all the childNodes of the root g element
803
+ */
804
+ ctx.prototype.__clearCanvas = function() {
805
+ var current = this.__closestGroupOrSvg(),
806
+ transform = current.getAttribute("transform");
807
+ var rootGroup = this.__root.childNodes[1];
808
+ var childNodes = rootGroup.childNodes;
809
+ for (var i = childNodes.length - 1; i >= 0; i--) {
810
+ if (childNodes[i]) {
811
+ rootGroup.removeChild(childNodes[i]);
812
+ }
813
+ }
814
+ this.__currentElement = rootGroup;
815
+ //reset __groupStack as all the child group nodes are all removed.
816
+ this.__groupStack = [];
817
+ if (transform) {
818
+ this.__addTransform(transform);
819
+ }
820
+ };
821
+
822
+ /**
823
+ * "Clears" a canvas by just drawing a white rectangle in the current group.
824
+ */
825
+ ctx.prototype.clearRect = function(x, y, width, height) {
826
+ //clear entire canvas
827
+ if (x === 0 && y === 0 && width === this.width && height === this.height) {
828
+ this.__clearCanvas();
829
+ return;
830
+ }
831
+ var rect, parent = this.__closestGroupOrSvg();
832
+ rect = this.__createElement("rect", {
833
+ x : x,
834
+ y : y,
835
+ width : width,
836
+ height : height,
837
+ fill : "#FFFFFF"
838
+ }, true);
839
+ parent.appendChild(rect);
840
+ };
841
+
842
+ /**
843
+ * Adds a linear gradient to a defs tag.
844
+ * Returns a canvas gradient object that has a reference to it's parent def
845
+ */
846
+ ctx.prototype.createLinearGradient = function(x1, y1, x2, y2){
847
+ var grad = this.__createElement("linearGradient", {
848
+ id : randomString(this.__ids),
849
+ x1 : x1+"px",
850
+ x2 : x2+"px",
851
+ y1 : y1+"px",
852
+ y2 : y2+"px",
853
+ "gradientUnits" : "userSpaceOnUse"
854
+ }, false);
855
+ this.__defs.appendChild(grad);
856
+ return new CanvasGradient(grad, this);
857
+ };
858
+
859
+ /**
860
+ * Adds a radial gradient to a defs tag.
861
+ * Returns a canvas gradient object that has a reference to it's parent def
862
+ */
863
+ ctx.prototype.createRadialGradient = function(x0, y0, r0, x1, y1, r1){
864
+ var grad = this.__createElement("radialGradient", {
865
+ id : randomString(this.__ids),
866
+ cx : x1+"px",
867
+ cy : y1+"px",
868
+ r : r1+"px",
869
+ fx : x0+"px",
870
+ fy : y0+"px",
871
+ "gradientUnits" : "userSpaceOnUse"
872
+ }, false);
873
+ this.__defs.appendChild(grad);
874
+ return new CanvasGradient(grad, this);
875
+
876
+ };
877
+
878
+ /**
879
+ * Parses the font string and returns svg mapping
880
+ * @private
881
+ */
882
+ ctx.prototype.__parseFont = function() {
883
+ var regex = /^\s*(?=(?:(?:[-a-z]+\s*){0,2}(italic|oblique))?)(?=(?:(?:[-a-z]+\s*){0,2}(small-caps))?)(?=(?:(?:[-a-z]+\s*){0,2}(bold(?:er)?|lighter|[1-9]00))?)(?:(?:normal|\1|\2|\3)\s*){0,3}((?:xx?-)?(?:small|large)|medium|smaller|larger|[.\d]+(?:\%|in|[cem]m|ex|p[ctx]))(?:\s*\/\s*(normal|[.\d]+(?:\%|in|[cem]m|ex|p[ctx])))?\s*([-,\'\"\sa-z]+?)\s*$/i;
884
+ var fontPart = regex.exec( this.font );
885
+ var data = {
886
+ style : fontPart[1] || 'normal',
887
+ size : fontPart[4] || '10px',
888
+ family : fontPart[6] || 'sans-serif',
889
+ weight: fontPart[3] || 'normal',
890
+ decoration : fontPart[2] || 'normal',
891
+ href : null
892
+ };
893
+
894
+ //canvas doesn't support underline natively, but we can pass this attribute
895
+ if(this.__fontUnderline === "underline") {
896
+ data.decoration = "underline";
897
+ }
898
+
899
+ //canvas also doesn't support linking, but we can pass this as well
900
+ if(this.__fontHref) {
901
+ data.href = this.__fontHref;
902
+ }
903
+
904
+ return data;
905
+ };
906
+
907
+ /**
908
+ * Helper to link text fragments
909
+ * @param font
910
+ * @param element
911
+ * @return {*}
912
+ * @private
913
+ */
914
+ ctx.prototype.__wrapTextLink = function(font, element) {
915
+ if(font.href) {
916
+ var a = this.__createElement("a");
917
+ a.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", font.href);
918
+ a.appendChild(element);
919
+ return a;
920
+ }
921
+ return element;
922
+ };
923
+
924
+ /**
925
+ * Fills or strokes text
926
+ * @param text
927
+ * @param x
928
+ * @param y
929
+ * @param action - stroke or fill
930
+ * @private
931
+ */
932
+ ctx.prototype.__applyText = function(text, x, y, action) {
933
+ var font = this.__parseFont(),
934
+ parent = this.__closestGroupOrSvg(),
935
+ textElement = this.__createElement("text", {
936
+ "font-family" : font.family,
937
+ "font-size" : font.size,
938
+ "font-style" : font.style,
939
+ "font-weight" : font.weight,
940
+ "text-decoration" : font.decoration,
941
+ "x" : x,
942
+ "y" : y,
943
+ "text-anchor": getTextAnchor(this.textAlign),
944
+ "dominant-baseline": getDominantBaseline(this.textBaseline)
945
+ }, true);
946
+
947
+ textElement.appendChild(this.__document.createTextNode(text));
948
+ this.__currentElement = textElement;
949
+ this.__applyStyleToCurrentElement(action);
950
+ parent.appendChild(this.__wrapTextLink(font,textElement));
951
+ };
952
+
953
+ /**
954
+ * Creates a text element
955
+ * @param text
956
+ * @param x
957
+ * @param y
958
+ */
959
+ ctx.prototype.fillText = function(text, x, y){
960
+ this.__applyText(text, x, y, "fill");
961
+ };
962
+
963
+ /**
964
+ * Strokes text
965
+ * @param text
966
+ * @param x
967
+ * @param y
968
+ */
969
+ ctx.prototype.strokeText = function(text, x, y){
970
+ this.__applyText(text, x, y, "stroke");
971
+ };
972
+
973
+ /**
974
+ * No need to implement this for svg.
975
+ * @param text
976
+ * @return {TextMetrics}
977
+ */
978
+ ctx.prototype.measureText = function(text){
979
+ this.__ctx.font = this.font;
980
+ return this.__ctx.measureText(text);
981
+ };
982
+
983
+ /**
984
+ * Arc command!
985
+ */
986
+ ctx.prototype.arc = function(x, y, radius, startAngle, endAngle, counterClockwise) {
987
+ // in canvas no circle is drawn if no angle is provided.
988
+ if (startAngle === endAngle) {
989
+ return;
990
+ }
991
+ startAngle = startAngle % (2*Math.PI);
992
+ endAngle = endAngle % (2*Math.PI);
993
+ if(startAngle === endAngle) {
994
+ //circle time! subtract some of the angle so svg is happy (svg elliptical arc can't draw a full circle)
995
+ endAngle = ((endAngle + (2*Math.PI)) - 0.001 * (counterClockwise ? -1 : 1)) % (2*Math.PI);
996
+ }
997
+ var endX = x+radius*Math.cos(endAngle),
998
+ endY = y+radius*Math.sin(endAngle),
999
+ startX = x+radius*Math.cos(startAngle),
1000
+ startY = y+radius*Math.sin(startAngle),
1001
+ sweepFlag = counterClockwise ? 0 : 1,
1002
+ largeArcFlag = 0,
1003
+ diff = endAngle - startAngle;
1004
+
1005
+ // https://github.com/gliffy/canvas2svg/issues/4
1006
+ if(diff < 0) {
1007
+ diff += 2*Math.PI;
1008
+ }
1009
+
1010
+ if(counterClockwise) {
1011
+ largeArcFlag = diff > Math.PI ? 0 : 1;
1012
+ } else {
1013
+ largeArcFlag = diff > Math.PI ? 1 : 0;
1014
+ }
1015
+
1016
+ this.lineTo(startX, startY);
1017
+ this.__addPathCommand(format("A {rx} {ry} {xAxisRotation} {largeArcFlag} {sweepFlag} {endX} {endY}",
1018
+ {rx:radius, ry:radius, xAxisRotation:0, largeArcFlag:largeArcFlag, sweepFlag:sweepFlag, endX:endX, endY:endY}));
1019
+
1020
+ this.__currentPosition = {x: endX, y: endY};
1021
+ };
1022
+
1023
+ /**
1024
+ * Generates a ClipPath from the clip command.
1025
+ */
1026
+ ctx.prototype.clip = function(){
1027
+ var group = this.__closestGroupOrSvg(),
1028
+ clipPath = this.__createElement("clipPath"),
1029
+ id = randomString(this.__ids),
1030
+ newGroup = this.__createElement("g");
1031
+
1032
+ this.__applyCurrentDefaultPath();
1033
+ group.removeChild(this.__currentElement);
1034
+ clipPath.setAttribute("id", id);
1035
+ clipPath.appendChild(this.__currentElement);
1036
+
1037
+ this.__defs.appendChild(clipPath);
1038
+
1039
+ //set the clip path to this group
1040
+ group.setAttribute("clip-path", format("url(#{id})", {id:id}));
1041
+
1042
+ //clip paths can be scaled and transformed, we need to add another wrapper group to avoid later transformations
1043
+ // to this path
1044
+ group.appendChild(newGroup);
1045
+
1046
+ this.__currentElement = newGroup;
1047
+
1048
+ };
1049
+
1050
+ /**
1051
+ * Draws a canvas, image or mock context to this canvas.
1052
+ * Note that all svg dom manipulation uses node.childNodes rather than node.children for IE support.
1053
+ * http://www.whatwg.org/specs/web-apps/current-work/multipage/the-canvas-element.html#dom-context-2d-drawimage
1054
+ */
1055
+ ctx.prototype.drawImage = function(){
1056
+ //convert arguments to a real array
1057
+ var args = Array.prototype.slice.call(arguments),
1058
+ image=args[0],
1059
+ dx, dy, dw, dh, sx=0, sy=0, sw, sh, parent, svg, defs, group,
1060
+ currentElement, svgImage, canvas, context, id;
1061
+
1062
+ if(args.length === 3) {
1063
+ dx = args[1];
1064
+ dy = args[2];
1065
+ sw = image.width;
1066
+ sh = image.height;
1067
+ dw = sw;
1068
+ dh = sh;
1069
+ } else if(args.length === 5) {
1070
+ dx = args[1];
1071
+ dy = args[2];
1072
+ dw = args[3];
1073
+ dh = args[4];
1074
+ sw = image.width;
1075
+ sh = image.height;
1076
+ } else if(args.length === 9) {
1077
+ sx = args[1];
1078
+ sy = args[2];
1079
+ sw = args[3];
1080
+ sh = args[4];
1081
+ dx = args[5];
1082
+ dy = args[6];
1083
+ dw = args[7];
1084
+ dh = args[8];
1085
+ } else {
1086
+ throw new Error("Inavlid number of arguments passed to drawImage: " + arguments.length);
1087
+ }
1088
+
1089
+ parent = this.__closestGroupOrSvg();
1090
+ currentElement = this.__currentElement;
1091
+ var translateDirective = "translate(" + dx + ", " + dy + ")";
1092
+ if(image instanceof ctx) {
1093
+ //canvas2svg mock canvas context. In the future we may want to clone nodes instead.
1094
+ //also I'm currently ignoring dw, dh, sw, sh, sx, sy for a mock context.
1095
+ svg = image.getSvg().cloneNode(true);
1096
+ if (svg.childNodes && svg.childNodes.length > 1) {
1097
+ defs = svg.childNodes[0];
1098
+ while(defs.childNodes.length) {
1099
+ id = defs.childNodes[0].getAttribute("id");
1100
+ this.__ids[id] = id;
1101
+ this.__defs.appendChild(defs.childNodes[0]);
1102
+ }
1103
+ group = svg.childNodes[1];
1104
+ if (group) {
1105
+ //save original transform
1106
+ var originTransform = group.getAttribute("transform");
1107
+ var transformDirective;
1108
+ if (originTransform) {
1109
+ transformDirective = originTransform+" "+translateDirective;
1110
+ } else {
1111
+ transformDirective = translateDirective;
1112
+ }
1113
+ group.setAttribute("transform", transformDirective);
1114
+ parent.appendChild(group);
1115
+ }
1116
+ }
1117
+ } else if(image.nodeName === "CANVAS" || image.nodeName === "IMG") {
1118
+ //canvas or image
1119
+ svgImage = this.__createElement("image");
1120
+ svgImage.setAttribute("width", dw);
1121
+ svgImage.setAttribute("height", dh);
1122
+ svgImage.setAttribute("preserveAspectRatio", "none");
1123
+
1124
+ if(sx || sy || sw !== image.width || sh !== image.height) {
1125
+ //crop the image using a temporary canvas
1126
+ canvas = this.__document.createElement("canvas");
1127
+ canvas.width = dw;
1128
+ canvas.height = dh;
1129
+ context = canvas.getContext("2d");
1130
+ context.drawImage(image, sx, sy, sw, sh, 0, 0, dw, dh);
1131
+ image = canvas;
1132
+ }
1133
+ svgImage.setAttribute("transform", translateDirective);
1134
+ svgImage.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href",
1135
+ image.nodeName === "CANVAS" ? image.toDataURL() : image.getAttribute("src"));
1136
+ parent.appendChild(svgImage);
1137
+ }
1138
+ };
1139
+
1140
+ /**
1141
+ * Generates a pattern tag
1142
+ */
1143
+ ctx.prototype.createPattern = function(image, repetition){
1144
+ var pattern = this.__document.createElementNS("http://www.w3.org/2000/svg", "pattern"), id = randomString(this.__ids),
1145
+ img;
1146
+ pattern.setAttribute("id", id);
1147
+ pattern.setAttribute("width", image.width);
1148
+ pattern.setAttribute("height", image.height);
1149
+ if(image.nodeName === "CANVAS" || image.nodeName === "IMG") {
1150
+ img = this.__document.createElementNS("http://www.w3.org/2000/svg", "image");
1151
+ img.setAttribute("width", image.width);
1152
+ img.setAttribute("height", image.height);
1153
+ img.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href",
1154
+ image.nodeName === "CANVAS" ? image.toDataURL() : image.getAttribute("src"));
1155
+ pattern.appendChild(img);
1156
+ this.__defs.appendChild(pattern);
1157
+ } else if(image instanceof ctx) {
1158
+ pattern.appendChild(image.__root.childNodes[1]);
1159
+ this.__defs.appendChild(pattern);
1160
+ }
1161
+ return new CanvasPattern(pattern, this);
1162
+ };
1163
+
1164
+ ctx.prototype.setLineDash = function(dashArray) {
1165
+ if (dashArray && dashArray.length > 0) {
1166
+ this.lineDash = dashArray.join(",");
1167
+ } else {
1168
+ this.lineDash = null;
1169
+ }
1170
+ };
1171
+
1172
+ /**
1173
+ * Not yet implemented
1174
+ */
1175
+ ctx.prototype.drawFocusRing = function(){};
1176
+ ctx.prototype.createImageData = function(){};
1177
+ ctx.prototype.getImageData = function(){};
1178
+ ctx.prototype.putImageData = function(){};
1179
+ ctx.prototype.globalCompositeOperation = function(){};
1180
+ ctx.prototype.setTransform = function(){};
1181
+
1182
+ //add options for alternative namespace
1183
+ if (typeof window === "object") {
1184
+ window.C2S = ctx;
1185
+ }
1186
+
1187
+ // CommonJS/Browserify
1188
+ if (typeof module === "object" && typeof module.exports === "object") {
1189
+ module.exports = ctx;
1190
+ }
1191
+
1192
+ }());
custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/favicon-active.ico ADDED
custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/favicon.ico ADDED
custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/notify.mp3 ADDED
Binary file (74.4 kB). View file
 
custom_nodes/ComfyUI-Custom-Scripts/web/js/autocompleter.js ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+ import { ComfyWidgets } from "../../../scripts/widgets.js";
3
+ import { api } from "../../../scripts/api.js";
4
+ import { $el, ComfyDialog } from "../../../scripts/ui.js";
5
+ import { TextAreaAutoComplete } from "./common/autocomplete.js";
6
+ import { ModelInfoDialog } from "./common/modelInfoDialog.js";
7
+ import { LoraInfoDialog } from "./modelInfo.js";
8
+
9
+ function parseCSV(csvText) {
10
+ const rows = [];
11
+ const delimiter = ",";
12
+ const quote = '"';
13
+ let currentField = "";
14
+ let inQuotedField = false;
15
+
16
+ function pushField() {
17
+ rows[rows.length - 1].push(currentField);
18
+ currentField = "";
19
+ inQuotedField = false;
20
+ }
21
+
22
+ rows.push([]); // Initialize the first row
23
+
24
+ for (let i = 0; i < csvText.length; i++) {
25
+ const char = csvText[i];
26
+ const nextChar = csvText[i + 1];
27
+
28
+ // Special handling for backslash escaped quotes
29
+ if (char === "\\" && nextChar === quote) {
30
+ currentField += quote;
31
+ i++;
32
+ }
33
+
34
+ if (!inQuotedField) {
35
+ if (char === quote) {
36
+ inQuotedField = true;
37
+ } else if (char === delimiter) {
38
+ pushField();
39
+ } else if (char === "\r" || char === "\n" || i === csvText.length - 1) {
40
+ pushField();
41
+ if (nextChar === "\n") {
42
+ i++; // Handle Windows line endings (\r\n)
43
+ }
44
+ rows.push([]); // Start a new row
45
+ } else {
46
+ currentField += char;
47
+ }
48
+ } else {
49
+ if (char === quote && nextChar === quote) {
50
+ currentField += quote;
51
+ i++; // Skip the next quote
52
+ } else if (char === quote) {
53
+ inQuotedField = false;
54
+ } else if (char === "\r" || char === "\n" || i === csvText.length - 1) {
55
+ // Dont allow new lines in quoted text, assume its wrong
56
+ const parsed = parseCSV(currentField);
57
+ rows.pop();
58
+ rows.push(...parsed);
59
+ inQuotedField = false;
60
+ currentField = "";
61
+ rows.push([]);
62
+ } else {
63
+ currentField += char;
64
+ }
65
+ }
66
+ }
67
+
68
+ if (currentField || csvText[csvText.length - 1] === ",") {
69
+ pushField();
70
+ }
71
+
72
+ // Remove the last row if it's empty
73
+ if (rows[rows.length - 1].length === 0) {
74
+ rows.pop();
75
+ }
76
+
77
+ return rows;
78
+ }
79
+
80
+ async function getCustomWords() {
81
+ const resp = await api.fetchApi("/pysssss/autocomplete", { cache: "no-store" });
82
+ if (resp.status === 200) {
83
+ return await resp.text();
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ async function addCustomWords(text) {
89
+ if (!text) {
90
+ text = await getCustomWords();
91
+ }
92
+ if (text) {
93
+ TextAreaAutoComplete.updateWords(
94
+ "pysssss.customwords",
95
+ parseCSV(text).reduce((p, n) => {
96
+ let text;
97
+ let priority;
98
+ let value;
99
+ let num;
100
+ switch (n.length) {
101
+ case 0:
102
+ return;
103
+ case 1:
104
+ // Single word
105
+ text = n[0];
106
+ break;
107
+ case 2:
108
+ // Word,[priority|alias]
109
+ num = +n[1];
110
+ if (isNaN(num)) {
111
+ text = n[0] + "🔄️" + n[1];
112
+ value = n[0];
113
+ } else {
114
+ text = n[0];
115
+ priority = num;
116
+ }
117
+ break;
118
+ case 4:
119
+ // a1111 csv format?
120
+ value = n[0];
121
+ priority = +n[2];
122
+ const aliases = n[3]?.trim();
123
+ if (aliases && aliases !== "null") { // Weird null in an example csv, maybe they are JSON.parsing the last column?
124
+ const split = aliases.split(",");
125
+ for (const text of split) {
126
+ p[text] = { text, priority, value };
127
+ }
128
+ }
129
+ text = value;
130
+ break;
131
+ default:
132
+ // Word,alias,priority
133
+ text = n[1];
134
+ value = n[0];
135
+ priority = +n[2];
136
+ break;
137
+ }
138
+ p[text] = { text, priority, value };
139
+ return p;
140
+ }, {})
141
+ );
142
+ }
143
+ }
144
+
145
+ function toggleLoras() {
146
+ [TextAreaAutoComplete.globalWords, TextAreaAutoComplete.globalWordsExclLoras] = [
147
+ TextAreaAutoComplete.globalWordsExclLoras,
148
+ TextAreaAutoComplete.globalWords,
149
+ ];
150
+ }
151
+
152
+ class EmbeddingInfoDialog extends ModelInfoDialog {
153
+ async addInfo() {
154
+ super.addInfo();
155
+ const info = await this.addCivitaiInfo();
156
+ if (info) {
157
+ $el("div", {
158
+ parent: this.content,
159
+ innerHTML: info.description,
160
+ style: {
161
+ maxHeight: "250px",
162
+ overflow: "auto",
163
+ },
164
+ });
165
+ }
166
+ }
167
+ }
168
+
169
+ class CustomWordsDialog extends ComfyDialog {
170
+ async show() {
171
+ const text = await getCustomWords();
172
+ this.words = $el("textarea", {
173
+ textContent: text,
174
+ style: {
175
+ width: "70vw",
176
+ height: "70vh",
177
+ },
178
+ });
179
+
180
+ const input = $el("input", {
181
+ style: {
182
+ flex: "auto",
183
+ },
184
+ value:
185
+ "https://gist.githubusercontent.com/pythongosssss/1d3efa6050356a08cea975183088159a/raw/a18fb2f94f9156cf4476b0c24a09544d6c0baec6/danbooru-tags.txt",
186
+ });
187
+
188
+ super.show(
189
+ $el(
190
+ "div",
191
+ {
192
+ style: {
193
+ display: "flex",
194
+ flexDirection: "column",
195
+ overflow: "hidden",
196
+ maxHeight: "100%",
197
+ },
198
+ },
199
+ [
200
+ $el("h2", {
201
+ textContent: "Custom Autocomplete Words",
202
+ style: {
203
+ color: "#fff",
204
+ marginTop: 0,
205
+ textAlign: "center",
206
+ fontFamily: "sans-serif",
207
+ },
208
+ }),
209
+ $el(
210
+ "div",
211
+ {
212
+ style: {
213
+ color: "#fff",
214
+ fontFamily: "sans-serif",
215
+ display: "flex",
216
+ alignItems: "center",
217
+ gap: "5px",
218
+ },
219
+ },
220
+ [
221
+ $el("label", { textContent: "Load Custom List: " }),
222
+ input,
223
+ $el("button", {
224
+ textContent: "Load",
225
+ onclick: async () => {
226
+ try {
227
+ const res = await fetch(input.value);
228
+ if (res.status !== 200) {
229
+ throw new Error("Error loading: " + res.status + " " + res.statusText);
230
+ }
231
+ this.words.value = await res.text();
232
+ } catch (error) {
233
+ alert("Error loading custom list, try manually copy + pasting the list");
234
+ }
235
+ },
236
+ }),
237
+ ]
238
+ ),
239
+ this.words,
240
+ ]
241
+ )
242
+ );
243
+ }
244
+
245
+ createButtons() {
246
+ const btns = super.createButtons();
247
+ const save = $el("button", {
248
+ type: "button",
249
+ textContent: "Save",
250
+ onclick: async (e) => {
251
+ try {
252
+ const res = await api.fetchApi("/pysssss/autocomplete", { method: "POST", body: this.words.value });
253
+ if (res.status !== 200) {
254
+ throw new Error("Error saving: " + res.status + " " + res.statusText);
255
+ }
256
+ save.textContent = "Saved!";
257
+ addCustomWords(this.words.value);
258
+ setTimeout(() => {
259
+ save.textContent = "Save";
260
+ }, 500);
261
+ } catch (error) {
262
+ alert("Error saving word list!");
263
+ console.error(error);
264
+ }
265
+ },
266
+ });
267
+
268
+ btns.unshift(save);
269
+ return btns;
270
+ }
271
+ }
272
+
273
+ const id = "pysssss.AutoCompleter";
274
+
275
+ app.registerExtension({
276
+ name: id,
277
+ init() {
278
+ const STRING = ComfyWidgets.STRING;
279
+ const SKIP_WIDGETS = new Set(["ttN xyPlot.x_values", "ttN xyPlot.y_values"]);
280
+ ComfyWidgets.STRING = function (node, inputName, inputData) {
281
+ const r = STRING.apply(this, arguments);
282
+
283
+ if (inputData[1]?.multiline) {
284
+ // Disabled on this input
285
+ const config = inputData[1]?.["pysssss.autocomplete"];
286
+ if (config === false) return r;
287
+
288
+ // In list of widgets to skip
289
+ const id = `${node.comfyClass}.${inputName}`;
290
+ if (SKIP_WIDGETS.has(id)) return r;
291
+
292
+ let words;
293
+ let separator;
294
+ if (typeof config === "object") {
295
+ separator = config.separator;
296
+ words = {};
297
+ if (config.words) {
298
+ // Custom wordlist, this will have been registered on setup
299
+ Object.assign(words, TextAreaAutoComplete.groups[node.comfyClass + "." + inputName] ?? {});
300
+ }
301
+
302
+ for (const item of config.groups ?? []) {
303
+ if (item === "*") {
304
+ // This widget wants all global words included
305
+ Object.assign(words, TextAreaAutoComplete.globalWords);
306
+ } else {
307
+ // This widget wants a specific group included
308
+ Object.assign(words, TextAreaAutoComplete.groups[item] ?? {});
309
+ }
310
+ }
311
+ }
312
+
313
+ new TextAreaAutoComplete(r.widget.inputEl, words, separator);
314
+ }
315
+
316
+ return r;
317
+ };
318
+
319
+ TextAreaAutoComplete.globalSeparator = localStorage.getItem(id + ".AutoSeparate") ?? ", ";
320
+ const enabledSetting = app.ui.settings.addSetting({
321
+ id,
322
+ name: "🐍 Text Autocomplete",
323
+ defaultValue: true,
324
+ type: (name, setter, value) => {
325
+ return $el("tr", [
326
+ $el("td", [
327
+ $el("label", {
328
+ for: id.replaceAll(".", "-"),
329
+ textContent: name,
330
+ }),
331
+ ]),
332
+ $el("td", [
333
+ $el(
334
+ "label",
335
+ {
336
+ textContent: "Enabled ",
337
+ style: {
338
+ display: "block",
339
+ },
340
+ },
341
+ [
342
+ $el("input", {
343
+ id: id.replaceAll(".", "-"),
344
+ type: "checkbox",
345
+ checked: value,
346
+ onchange: (event) => {
347
+ const checked = !!event.target.checked;
348
+ TextAreaAutoComplete.enabled = checked;
349
+ setter(checked);
350
+ },
351
+ }),
352
+ ]
353
+ ),
354
+ $el(
355
+ "label.comfy-tooltip-indicator",
356
+ {
357
+ title: "This requires other ComfyUI nodes/extensions that support using LoRAs in the prompt.",
358
+ textContent: "Loras enabled ",
359
+ style: {
360
+ display: "block",
361
+ },
362
+ },
363
+ [
364
+ $el("input", {
365
+ type: "checkbox",
366
+ checked: !!TextAreaAutoComplete.lorasEnabled,
367
+ onchange: (event) => {
368
+ const checked = !!event.target.checked;
369
+ TextAreaAutoComplete.lorasEnabled = checked;
370
+ toggleLoras();
371
+ localStorage.setItem(id + ".ShowLoras", TextAreaAutoComplete.lorasEnabled);
372
+ },
373
+ }),
374
+ ]
375
+ ),
376
+ $el(
377
+ "label",
378
+ {
379
+ textContent: "Auto-insert comma ",
380
+ style: {
381
+ display: "block",
382
+ },
383
+ },
384
+ [
385
+ $el("input", {
386
+ type: "checkbox",
387
+ checked: !!TextAreaAutoComplete.globalSeparator,
388
+ onchange: (event) => {
389
+ const checked = !!event.target.checked;
390
+ TextAreaAutoComplete.globalSeparator = checked ? ", " : "";
391
+ localStorage.setItem(id + ".AutoSeparate", TextAreaAutoComplete.globalSeparator);
392
+ },
393
+ }),
394
+ ]
395
+ ),
396
+ $el(
397
+ "label",
398
+ {
399
+ textContent: "Replace _ with space ",
400
+ style: {
401
+ display: "block",
402
+ },
403
+ },
404
+ [
405
+ $el("input", {
406
+ type: "checkbox",
407
+ checked: !!TextAreaAutoComplete.replacer,
408
+ onchange: (event) => {
409
+ const checked = !!event.target.checked;
410
+ TextAreaAutoComplete.replacer = checked ? (v) => v.replaceAll("_", " ") : undefined;
411
+ localStorage.setItem(id + ".ReplaceUnderscore", checked);
412
+ },
413
+ }),
414
+ ]
415
+ ),
416
+ $el(
417
+ "label",
418
+ {
419
+ textContent: "Insert suggestion on: ",
420
+ style: {
421
+ display: "block",
422
+ },
423
+ },
424
+ [
425
+ $el(
426
+ "label",
427
+ {
428
+ textContent: "Tab",
429
+ style: {
430
+ display: "block",
431
+ marginLeft: "20px",
432
+ },
433
+ },
434
+ [
435
+ $el("input", {
436
+ type: "checkbox",
437
+ checked: !!TextAreaAutoComplete.insertOnTab,
438
+ onchange: (event) => {
439
+ const checked = !!event.target.checked;
440
+ TextAreaAutoComplete.insertOnTab = checked;
441
+ localStorage.setItem(id + ".InsertOnTab", checked);
442
+ },
443
+ }),
444
+ ]
445
+ ),
446
+ $el(
447
+ "label",
448
+ {
449
+ textContent: "Enter",
450
+ style: {
451
+ display: "block",
452
+ marginLeft: "20px",
453
+ },
454
+ },
455
+ [
456
+ $el("input", {
457
+ type: "checkbox",
458
+ checked: !!TextAreaAutoComplete.insertOnEnter,
459
+ onchange: (event) => {
460
+ const checked = !!event.target.checked;
461
+ TextAreaAutoComplete.insertOnEnter = checked;
462
+ localStorage.setItem(id + ".InsertOnEnter", checked);
463
+ },
464
+ }),
465
+ ]
466
+ ),
467
+ ]
468
+ ),
469
+ $el(
470
+ "label",
471
+ {
472
+ textContent: "Max suggestions: ",
473
+ style: {
474
+ display: "block",
475
+ },
476
+ },
477
+ [
478
+ $el("input", {
479
+ type: "number",
480
+ value: +TextAreaAutoComplete.suggestionCount,
481
+ style: {
482
+ width: "80px"
483
+ },
484
+ onchange: (event) => {
485
+ const value = +event.target.value;
486
+ TextAreaAutoComplete.suggestionCount = value;;
487
+ localStorage.setItem(id + ".SuggestionCount", TextAreaAutoComplete.suggestionCount);
488
+ },
489
+ }),
490
+ ]
491
+ ),
492
+ $el("button", {
493
+ textContent: "Manage Custom Words",
494
+ onclick: () => {
495
+ try {
496
+ // Try closing old settings window
497
+ if (typeof app.ui.settings.element?.close === "function") {
498
+ app.ui.settings.element.close();
499
+ }
500
+ } catch (error) {
501
+ }
502
+ try {
503
+ // Try closing new vue dialog
504
+ document.querySelector(".p-dialog-close-button").click();
505
+ } catch (error) {
506
+ // Fallback to just hiding the element
507
+ app.ui.settings.element.style.display = "none";
508
+ }
509
+
510
+ new CustomWordsDialog().show();
511
+ },
512
+ style: {
513
+ fontSize: "14px",
514
+ display: "block",
515
+ marginTop: "5px",
516
+ },
517
+ }),
518
+ ]),
519
+ ]);
520
+ },
521
+ });
522
+
523
+ TextAreaAutoComplete.enabled = enabledSetting.value;
524
+ TextAreaAutoComplete.replacer = localStorage.getItem(id + ".ReplaceUnderscore") === "true" ? (v) => v.replaceAll("_", " ") : undefined;
525
+ TextAreaAutoComplete.insertOnTab = localStorage.getItem(id + ".InsertOnTab") !== "false";
526
+ TextAreaAutoComplete.insertOnEnter = localStorage.getItem(id + ".InsertOnEnter") !== "false";
527
+ TextAreaAutoComplete.lorasEnabled = localStorage.getItem(id + ".ShowLoras") === "true";
528
+ TextAreaAutoComplete.suggestionCount = +localStorage.getItem(id + ".SuggestionCount") || 20;
529
+ },
530
+ setup() {
531
+ async function addEmbeddings() {
532
+ const embeddings = await api.getEmbeddings();
533
+ const words = {};
534
+ words["embedding:"] = { text: "embedding:" };
535
+
536
+ for (const emb of embeddings) {
537
+ const v = `embedding:${emb}`;
538
+ words[v] = {
539
+ text: v,
540
+ info: () => new EmbeddingInfoDialog(emb).show("embeddings", emb),
541
+ use_replacer: false,
542
+ };
543
+ }
544
+
545
+ TextAreaAutoComplete.updateWords("pysssss.embeddings", words);
546
+ }
547
+
548
+ async function addLoras() {
549
+ let loras;
550
+ try {
551
+ loras = LiteGraph.registered_node_types["LoraLoader"]?.nodeData.input.required.lora_name[0];
552
+ } catch (error) {}
553
+
554
+ if (!loras?.length) {
555
+ loras = await api.fetchApi("/pysssss/loras", { cache: "no-store" }).then((res) => res.json());
556
+ }
557
+
558
+ const words = {};
559
+ words["lora:"] = { text: "lora:" };
560
+
561
+ for (const lora of loras) {
562
+ const v = `<lora:${lora}:1.0>`;
563
+ words[v] = {
564
+ text: v,
565
+ info: () => new LoraInfoDialog(lora).show("loras", lora),
566
+ use_replacer: false,
567
+ };
568
+ }
569
+
570
+ TextAreaAutoComplete.updateWords("pysssss.loras", words);
571
+ }
572
+
573
+ // store global words with/without loras
574
+ Promise.all([addEmbeddings(), addCustomWords()])
575
+ .then(() => {
576
+ TextAreaAutoComplete.globalWordsExclLoras = Object.assign({}, TextAreaAutoComplete.globalWords);
577
+ })
578
+ .then(addLoras)
579
+ .then(() => {
580
+ if (!TextAreaAutoComplete.lorasEnabled) {
581
+ toggleLoras(); // off by default
582
+ }
583
+ });
584
+ },
585
+ beforeRegisterNodeDef(_, def) {
586
+ // Process each input to see if there is a custom word list for
587
+ // { input: { required: { something: ["STRING", { "pysssss.autocomplete": ["groupid", ["custom", "words"] ] }] } } }
588
+ const inputs = { ...def.input?.required, ...def.input?.optional };
589
+ for (const input in inputs) {
590
+ const config = inputs[input][1]?.["pysssss.autocomplete"];
591
+ if (!config) continue;
592
+ if (typeof config === "object" && config.words) {
593
+ const words = {};
594
+ for (const text of config.words || []) {
595
+ const obj = typeof text === "string" ? { text } : text;
596
+ words[obj.text] = obj;
597
+ }
598
+ TextAreaAutoComplete.updateWords(def.name + "." + input, words, false);
599
+ }
600
+ }
601
+ },
602
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/betterCombos.js ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+ import { ComfyWidgets } from "../../../scripts/widgets.js";
3
+ import { $el } from "../../../scripts/ui.js";
4
+ import { api } from "../../../scripts/api.js";
5
+
6
+ const CHECKPOINT_LOADER = "CheckpointLoader|pysssss";
7
+ const LORA_LOADER = "LoraLoader|pysssss";
8
+
9
+ function getType(node) {
10
+ if (node.comfyClass === CHECKPOINT_LOADER) {
11
+ return "checkpoints";
12
+ }
13
+ return "loras";
14
+ }
15
+
16
+ app.registerExtension({
17
+ name: "pysssss.Combo++",
18
+ init() {
19
+ $el("style", {
20
+ textContent: `
21
+ .litemenu-entry:hover .pysssss-combo-image {
22
+ display: block;
23
+ }
24
+ .pysssss-combo-image {
25
+ display: none;
26
+ position: absolute;
27
+ left: 0;
28
+ top: 0;
29
+ transform: translate(-100%, 0);
30
+ width: 384px;
31
+ height: 384px;
32
+ background-size: contain;
33
+ background-position: top right;
34
+ background-repeat: no-repeat;
35
+ filter: brightness(65%);
36
+ }
37
+ `,
38
+ parent: document.body,
39
+ });
40
+
41
+ const submenuSetting = app.ui.settings.addSetting({
42
+ id: "pysssss.Combo++.Submenu",
43
+ name: "🐍 Enable submenu in custom nodes",
44
+ defaultValue: true,
45
+ type: "boolean",
46
+ });
47
+
48
+ // Ensure hook callbacks are available
49
+ const getOrSet = (target, name, create) => {
50
+ if (name in target) return target[name];
51
+ return (target[name] = create());
52
+ };
53
+ const symbol = getOrSet(window, "__pysssss__", () => Symbol("__pysssss__"));
54
+ const store = getOrSet(window, symbol, () => ({}));
55
+ const contextMenuHook = getOrSet(store, "contextMenuHook", () => ({}));
56
+ for (const e of ["ctor", "preAddItem", "addItem"]) {
57
+ if (!contextMenuHook[e]) {
58
+ contextMenuHook[e] = [];
59
+ }
60
+ }
61
+ // // Checks if this is a custom combo item
62
+ const isCustomItem = (value) => value && typeof value === "object" && "image" in value && value.content;
63
+ // Simple check for what separator to split by
64
+ const splitBy = (navigator.platform || navigator.userAgent).includes("Win") ? /\/|\\/ : /\//;
65
+
66
+ contextMenuHook["ctor"].push(function (values, options) {
67
+ // Copy the class from the parent so if we are dark we are also dark
68
+ // this enables the filter box
69
+ if (options.parentMenu?.options?.className === "dark") {
70
+ options.className = "dark";
71
+ }
72
+ });
73
+
74
+ function encodeRFC3986URIComponent(str) {
75
+ return encodeURIComponent(str).replace(
76
+ /[!'()*]/g,
77
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
78
+ );
79
+ }
80
+
81
+ // After an element is created for an item, add an image if it has one
82
+ contextMenuHook["addItem"].push(function (el, menu, [name, value, options]) {
83
+ if (el && isCustomItem(value) && value?.image && !value.submenu) {
84
+ el.textContent += " *";
85
+ $el("div.pysssss-combo-image", {
86
+ parent: el,
87
+ style: {
88
+ backgroundImage: `url(/pysssss/view/${encodeRFC3986URIComponent(value.image)})`,
89
+ },
90
+ });
91
+ }
92
+ });
93
+
94
+ function buildMenu(widget, values) {
95
+ const lookup = {
96
+ "": { options: [] },
97
+ };
98
+
99
+ // Split paths into menu structure
100
+ for (const value of values) {
101
+ const split = value.content.split(splitBy);
102
+ let path = "";
103
+ for (let i = 0; i < split.length; i++) {
104
+ const s = split[i];
105
+ const last = i === split.length - 1;
106
+ if (last) {
107
+ // Leaf node, manually add handler that sets the lora
108
+ lookup[path].options.push({
109
+ ...value,
110
+ title: s,
111
+ callback: () => {
112
+ widget.value = value;
113
+ widget.callback(value);
114
+ app.graph.setDirtyCanvas(true);
115
+ },
116
+ });
117
+ } else {
118
+ const prevPath = path;
119
+ path += s + splitBy;
120
+ if (!lookup[path]) {
121
+ const sub = {
122
+ title: s,
123
+ submenu: {
124
+ options: [],
125
+ title: s,
126
+ },
127
+ };
128
+
129
+ // Add to tree
130
+ lookup[path] = sub.submenu;
131
+ lookup[prevPath].options.push(sub);
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ return lookup[""].options;
138
+ }
139
+
140
+ // Override COMBO widgets to patch their values
141
+ const combo = ComfyWidgets["COMBO"];
142
+ ComfyWidgets["COMBO"] = function (node, inputName, inputData) {
143
+ const type = inputData[0];
144
+ const res = combo.apply(this, arguments);
145
+ if (isCustomItem(type[0])) {
146
+ let value = res.widget.value;
147
+ let values = res.widget.options.values;
148
+ let menu = null;
149
+
150
+ // Override the option values to check if we should render a menu structure
151
+ Object.defineProperty(res.widget.options, "values", {
152
+ get() {
153
+ let v = values;
154
+
155
+ if (submenuSetting.value) {
156
+ if (!menu) {
157
+ // Only build the menu once
158
+ menu = buildMenu(res.widget, values);
159
+ }
160
+ v = menu;
161
+ }
162
+
163
+ const valuesIncludes = v.includes;
164
+ v.includes = function (searchElement) {
165
+ const includesFromMenuItems = function (items) {
166
+ for (const item of items) {
167
+ if (includesFromMenuItem(item)) {
168
+ return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+ const includesFromMenuItem = function (item) {
174
+ if (item.submenu) {
175
+ return includesFromMenuItems(item.submenu.options)
176
+ } else {
177
+ return item.content === searchElement.content;
178
+ }
179
+ }
180
+
181
+ const includes = valuesIncludes.apply(this, arguments) || includesFromMenuItems(this);
182
+ return includes;
183
+ }
184
+
185
+ return v;
186
+ },
187
+ set(v) {
188
+ // Options are changing (refresh) so reset the menu so it can be rebuilt if required
189
+ values = v;
190
+ menu = null;
191
+ },
192
+ });
193
+
194
+ Object.defineProperty(res.widget, "value", {
195
+ get() {
196
+ // HACK: litegraph supports rendering items with "content" in the menu, but not on the widget
197
+ // This detects when its being called by the widget drawing and just returns the text
198
+ // Also uses the content for the same image replacement value
199
+ if (res.widget) {
200
+ const stack = new Error().stack;
201
+ if (stack.includes("drawNodeWidgets") || stack.includes("saveImageExtraOutput")) {
202
+ return (value || type[0]).content;
203
+ }
204
+ }
205
+ return value;
206
+ },
207
+ set(v) {
208
+ if (v?.submenu) {
209
+ // Dont allow selection of submenus
210
+ return;
211
+ }
212
+ value = v;
213
+ },
214
+ });
215
+ }
216
+
217
+ return res;
218
+ };
219
+ },
220
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
221
+ const isCkpt = nodeType.comfyClass === CHECKPOINT_LOADER;
222
+ const isLora = nodeType.comfyClass === LORA_LOADER;
223
+ if (isCkpt || isLora) {
224
+ const onAdded = nodeType.prototype.onAdded;
225
+ nodeType.prototype.onAdded = function () {
226
+ onAdded?.apply(this, arguments);
227
+ const { widget: exampleList } = ComfyWidgets["COMBO"](this, "example", [[""]], app);
228
+
229
+ let exampleWidget;
230
+
231
+ const get = async (route, suffix) => {
232
+ const url = encodeURIComponent(`${getType(nodeType)}${suffix || ""}`);
233
+ return await api.fetchApi(`/pysssss/${route}/${url}`);
234
+ };
235
+
236
+ const getExample = async () => {
237
+ if (exampleList.value === "[none]") {
238
+ if (exampleWidget) {
239
+ exampleWidget.inputEl.remove();
240
+ exampleWidget = null;
241
+ this.widgets.length -= 1;
242
+ }
243
+ return;
244
+ }
245
+
246
+ const v = this.widgets[0].value.content;
247
+ const pos = v.lastIndexOf(".");
248
+ const name = v.substr(0, pos);
249
+ let exampleName = exampleList.value;
250
+ let viewPath = `/${name}`;
251
+ if (exampleName === "notes") {
252
+ viewPath += ".txt";
253
+ } else {
254
+ viewPath += `/${exampleName}`;
255
+ }
256
+ const example = await (await get("view", viewPath)).text();
257
+ if (!exampleWidget) {
258
+ exampleWidget = ComfyWidgets["STRING"](this, "prompt", ["STRING", { multiline: true }], app).widget;
259
+ exampleWidget.inputEl.readOnly = true;
260
+ exampleWidget.inputEl.style.opacity = 0.6;
261
+ }
262
+ exampleWidget.value = example;
263
+ };
264
+
265
+ const exampleCb = exampleList.callback;
266
+ exampleList.callback = function () {
267
+ getExample();
268
+ return exampleCb?.apply(this, arguments) ?? exampleList.value;
269
+ };
270
+
271
+
272
+ const listExamples = async () => {
273
+ exampleList.disabled = true;
274
+ exampleList.options.values = ["[none]"];
275
+ exampleList.value = "[none]";
276
+ let examples = [];
277
+ if (this.widgets[0].value?.content) {
278
+ try {
279
+ examples = await (await get("examples", `/${this.widgets[0].value.content}`)).json();
280
+ } catch (error) {}
281
+ }
282
+ exampleList.options.values = ["[none]", ...examples];
283
+ exampleList.value = exampleList.options.values[+!!examples.length];
284
+ exampleList.callback();
285
+ exampleList.disabled = !examples.length;
286
+ app.graph.setDirtyCanvas(true, true);
287
+ };
288
+
289
+ // Expose function to update examples
290
+ nodeType.prototype["pysssss.updateExamples"] = listExamples;
291
+
292
+ const modelWidget = this.widgets[0];
293
+ const modelCb = modelWidget.callback;
294
+ let prev = undefined;
295
+ modelWidget.callback = function () {
296
+ const ret = modelCb?.apply(this, arguments) ?? modelWidget.value;
297
+ let v = ret;
298
+ if (ret?.content) {
299
+ v = ret.content;
300
+ }
301
+ if (prev !== v) {
302
+ listExamples();
303
+ prev = v;
304
+ }
305
+ return ret;
306
+ };
307
+ setTimeout(() => {
308
+ modelWidget.callback();
309
+ }, 30);
310
+ };
311
+
312
+ // Prevent adding HIDDEN inputs
313
+ const addInput = nodeType.prototype.addInput ?? LGraphNode.prototype.addInput;
314
+ nodeType.prototype.addInput = function (_, type) {
315
+ if (type === "HIDDEN") return;
316
+ return addInput.apply(this, arguments);
317
+ };
318
+ }
319
+
320
+ const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
321
+ nodeType.prototype.getExtraMenuOptions = function (_, options) {
322
+ if (this.imgs) {
323
+ // If this node has images then we add an open in new tab item
324
+ let img;
325
+ if (this.imageIndex != null) {
326
+ // An image is selected so select that
327
+ img = this.imgs[this.imageIndex];
328
+ } else if (this.overIndex != null) {
329
+ // No image is selected but one is hovered
330
+ img = this.imgs[this.overIndex];
331
+ }
332
+ if (img) {
333
+ const nodes = app.graph._nodes.filter(
334
+ (n) => n.comfyClass === LORA_LOADER || n.comfyClass === CHECKPOINT_LOADER
335
+ );
336
+ if (nodes.length) {
337
+ options.unshift({
338
+ content: "Save as Preview",
339
+ submenu: {
340
+ options: nodes.map((n) => ({
341
+ content: n.widgets[0].value.content,
342
+ callback: async () => {
343
+ const url = new URL(img.src);
344
+ const { image } = await api.fetchApi(
345
+ "/pysssss/save/" + encodeURIComponent(`${getType(n)}/${n.widgets[0].value.content}`),
346
+ {
347
+ method: "POST",
348
+ body: JSON.stringify({
349
+ filename: url.searchParams.get("filename"),
350
+ subfolder: url.searchParams.get("subfolder"),
351
+ type: url.searchParams.get("type"),
352
+ }),
353
+ headers: {
354
+ "content-type": "application/json",
355
+ },
356
+ }
357
+ );
358
+ n.widgets[0].value.image = image;
359
+ app.refreshComboInNodes();
360
+ },
361
+ })),
362
+ },
363
+ });
364
+ }
365
+ }
366
+ }
367
+ return getExtraMenuOptions?.apply(this, arguments);
368
+ };
369
+ },
370
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/autocomplete.css ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .pysssss-autocomplete {
2
+ color: var(--descrip-text);
3
+ background-color: var(--comfy-menu-bg);
4
+ position: absolute;
5
+ font-family: sans-serif;
6
+ box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4);
7
+ z-index: 9999;
8
+ overflow: auto;
9
+ }
10
+
11
+ .pysssss-autocomplete-item {
12
+ cursor: pointer;
13
+ padding: 3px 7px;
14
+ display: flex;
15
+ border-left: 3px solid transparent;
16
+ align-items: center;
17
+ }
18
+
19
+ .pysssss-autocomplete-item--selected {
20
+ border-left-color: dodgerblue;
21
+ }
22
+
23
+ .pysssss-autocomplete-highlight {
24
+ font-weight: bold;
25
+ text-decoration: underline;
26
+ text-decoration-color: dodgerblue;
27
+ }
28
+
29
+ .pysssss-autocomplete-pill {
30
+ margin-left: auto;
31
+ font-size: 10px;
32
+ color: #fff;
33
+ padding: 2px 4px 2px 14px;
34
+ position: relative;
35
+ }
36
+
37
+ .pysssss-autocomplete-pill::after {
38
+ content: "";
39
+ display: block;
40
+ background: rgba(255, 255, 255, 0.25);
41
+ width: calc(100% - 10px);
42
+ height: 100%;
43
+ position: absolute;
44
+ left: 10px;
45
+ top: 0;
46
+ border-radius: 5px;
47
+ }
48
+
49
+ .pysssss-autocomplete-pill + .pysssss-autocomplete-pill {
50
+ margin-left: 0;
51
+ }
52
+
53
+ .pysssss-autocomplete-item-info {
54
+ margin-left: auto;
55
+ transition: filter 0.2s;
56
+ will-change: filter;
57
+ text-decoration: none;
58
+ padding-left: 10px;
59
+ }
60
+ .pysssss-autocomplete-item-info:hover {
61
+ filter: invert(1);
62
+ }
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/autocomplete.js ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $el } from "../../../../scripts/ui.js";
2
+ import { addStylesheet } from "./utils.js";
3
+
4
+ addStylesheet(import.meta.url);
5
+
6
+ /*
7
+ https://github.com/component/textarea-caret-position
8
+ The MIT License (MIT)
9
+
10
+ Copyright (c) 2015 Jonathan Ong [email protected]
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
17
+ */
18
+ const getCaretCoordinates = (function () {
19
+ // We'll copy the properties below into the mirror div.
20
+ // Note that some browsers, such as Firefox, do not concatenate properties
21
+ // into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
22
+ // so we have to list every single property explicitly.
23
+ var properties = [
24
+ "direction", // RTL support
25
+ "boxSizing",
26
+ "width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
27
+ "height",
28
+ "overflowX",
29
+ "overflowY", // copy the scrollbar for IE
30
+
31
+ "borderTopWidth",
32
+ "borderRightWidth",
33
+ "borderBottomWidth",
34
+ "borderLeftWidth",
35
+ "borderStyle",
36
+
37
+ "paddingTop",
38
+ "paddingRight",
39
+ "paddingBottom",
40
+ "paddingLeft",
41
+
42
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/font
43
+ "fontStyle",
44
+ "fontVariant",
45
+ "fontWeight",
46
+ "fontStretch",
47
+ "fontSize",
48
+ "fontSizeAdjust",
49
+ "lineHeight",
50
+ "fontFamily",
51
+
52
+ "textAlign",
53
+ "textTransform",
54
+ "textIndent",
55
+ "textDecoration", // might not make a difference, but better be safe
56
+
57
+ "letterSpacing",
58
+ "wordSpacing",
59
+
60
+ "tabSize",
61
+ "MozTabSize",
62
+ ];
63
+
64
+ var isBrowser = typeof window !== "undefined";
65
+ var isFirefox = isBrowser && window.mozInnerScreenX != null;
66
+
67
+ return function getCaretCoordinates(element, position, options) {
68
+ if (!isBrowser) {
69
+ throw new Error("textarea-caret-position#getCaretCoordinates should only be called in a browser");
70
+ }
71
+
72
+ var debug = (options && options.debug) || false;
73
+ if (debug) {
74
+ var el = document.querySelector("#input-textarea-caret-position-mirror-div");
75
+ if (el) el.parentNode.removeChild(el);
76
+ }
77
+
78
+ // The mirror div will replicate the textarea's style
79
+ var div = document.createElement("div");
80
+ div.id = "input-textarea-caret-position-mirror-div";
81
+ document.body.appendChild(div);
82
+
83
+ var style = div.style;
84
+ var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
85
+ var isInput = element.nodeName === "INPUT";
86
+
87
+ // Default textarea styles
88
+ style.whiteSpace = "pre-wrap";
89
+ if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
90
+
91
+ // Position off-screen
92
+ style.position = "absolute"; // required to return coordinates properly
93
+ if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
94
+
95
+ // Transfer the element's properties to the div
96
+ properties.forEach(function (prop) {
97
+ if (isInput && prop === "lineHeight") {
98
+ // Special case for <input>s because text is rendered centered and line height may be != height
99
+ if (computed.boxSizing === "border-box") {
100
+ var height = parseInt(computed.height);
101
+ var outerHeight =
102
+ parseInt(computed.paddingTop) +
103
+ parseInt(computed.paddingBottom) +
104
+ parseInt(computed.borderTopWidth) +
105
+ parseInt(computed.borderBottomWidth);
106
+ var targetHeight = outerHeight + parseInt(computed.lineHeight);
107
+ if (height > targetHeight) {
108
+ style.lineHeight = height - outerHeight + "px";
109
+ } else if (height === targetHeight) {
110
+ style.lineHeight = computed.lineHeight;
111
+ } else {
112
+ style.lineHeight = 0;
113
+ }
114
+ } else {
115
+ style.lineHeight = computed.height;
116
+ }
117
+ } else {
118
+ style[prop] = computed[prop];
119
+ }
120
+ });
121
+
122
+ if (isFirefox) {
123
+ // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
124
+ if (element.scrollHeight > parseInt(computed.height)) style.overflowY = "scroll";
125
+ } else {
126
+ style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
127
+ }
128
+
129
+ div.textContent = element.value.substring(0, position);
130
+ // The second special handling for input type="text" vs textarea:
131
+ // spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
132
+ if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
133
+
134
+ var span = document.createElement("span");
135
+ // Wrapping must be replicated *exactly*, including when a long word gets
136
+ // onto the next line, with whitespace at the end of the line before (#7).
137
+ // The *only* reliable way to do that is to copy the *entire* rest of the
138
+ // textarea's content into the <span> created at the caret position.
139
+ // For inputs, just '.' would be enough, but no need to bother.
140
+ span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all
141
+ div.appendChild(span);
142
+
143
+ var coordinates = {
144
+ top: span.offsetTop + parseInt(computed["borderTopWidth"]),
145
+ left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
146
+ height: parseInt(computed["lineHeight"]),
147
+ };
148
+
149
+ if (debug) {
150
+ span.style.backgroundColor = "#aaa";
151
+ } else {
152
+ document.body.removeChild(div);
153
+ }
154
+
155
+ return coordinates;
156
+ };
157
+ })();
158
+
159
+ /*
160
+ Key functions from:
161
+ https://github.com/yuku/textcomplete
162
+ © Yuku Takahashi - This software is licensed under the MIT license.
163
+
164
+ The MIT License (MIT)
165
+
166
+ Copyright (c) 2015 Jonathan Ong [email protected]
167
+
168
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
169
+
170
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
171
+
172
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
173
+ */
174
+ const CHAR_CODE_ZERO = "0".charCodeAt(0);
175
+ const CHAR_CODE_NINE = "9".charCodeAt(0);
176
+
177
+ class TextAreaCaretHelper {
178
+ constructor(el, getScale) {
179
+ this.el = el;
180
+ this.getScale = getScale;
181
+ }
182
+
183
+ #calculateElementOffset() {
184
+ const rect = this.el.getBoundingClientRect();
185
+ const owner = this.el.ownerDocument;
186
+ if (owner == null) {
187
+ throw new Error("Given element does not belong to document");
188
+ }
189
+ const { defaultView, documentElement } = owner;
190
+ if (defaultView == null) {
191
+ throw new Error("Given element does not belong to window");
192
+ }
193
+ const offset = {
194
+ top: rect.top + defaultView.pageYOffset,
195
+ left: rect.left + defaultView.pageXOffset,
196
+ };
197
+ if (documentElement) {
198
+ offset.top -= documentElement.clientTop;
199
+ offset.left -= documentElement.clientLeft;
200
+ }
201
+ return offset;
202
+ }
203
+
204
+ #isDigit(charCode) {
205
+ return CHAR_CODE_ZERO <= charCode && charCode <= CHAR_CODE_NINE;
206
+ }
207
+
208
+ #getLineHeightPx() {
209
+ const computedStyle = getComputedStyle(this.el);
210
+ const lineHeight = computedStyle.lineHeight;
211
+ // If the char code starts with a digit, it is either a value in pixels,
212
+ // or unitless, as per:
213
+ // https://drafts.csswg.org/css2/visudet.html#propdef-line-height
214
+ // https://drafts.csswg.org/css2/cascade.html#computed-value
215
+ if (this.#isDigit(lineHeight.charCodeAt(0))) {
216
+ const floatLineHeight = parseFloat(lineHeight);
217
+ // In real browsers the value is *always* in pixels, even for unit-less
218
+ // line-heights. However, we still check as per the spec.
219
+ return this.#isDigit(lineHeight.charCodeAt(lineHeight.length - 1))
220
+ ? floatLineHeight * parseFloat(computedStyle.fontSize)
221
+ : floatLineHeight;
222
+ }
223
+ // Otherwise, the value is "normal".
224
+ // If the line-height is "normal", calculate by font-size
225
+ return this.#calculateLineHeightPx(this.el.nodeName, computedStyle);
226
+ }
227
+
228
+ /**
229
+ * Returns calculated line-height of the given node in pixels.
230
+ */
231
+ #calculateLineHeightPx(nodeName, computedStyle) {
232
+ const body = document.body;
233
+ if (!body) return 0;
234
+
235
+ const tempNode = document.createElement(nodeName);
236
+ tempNode.innerHTML = "&nbsp;";
237
+ Object.assign(tempNode.style, {
238
+ fontSize: computedStyle.fontSize,
239
+ fontFamily: computedStyle.fontFamily,
240
+ padding: "0",
241
+ position: "absolute",
242
+ });
243
+ body.appendChild(tempNode);
244
+
245
+ // Make sure textarea has only 1 row
246
+ if (tempNode instanceof HTMLTextAreaElement) {
247
+ tempNode.rows = 1;
248
+ }
249
+
250
+ // Assume the height of the element is the line-height
251
+ const height = tempNode.offsetHeight;
252
+ body.removeChild(tempNode);
253
+
254
+ return height;
255
+ }
256
+
257
+ getCursorOffset() {
258
+ const scale = this.getScale();
259
+ const elOffset = this.#calculateElementOffset();
260
+ const elScroll = this.#getElScroll();
261
+ const cursorPosition = this.#getCursorPosition();
262
+ const lineHeight = this.#getLineHeightPx();
263
+ const top = elOffset.top - (elScroll.top * scale) + (cursorPosition.top + lineHeight) * scale;
264
+ const left = elOffset.left - elScroll.left + cursorPosition.left;
265
+ const clientTop = this.el.getBoundingClientRect().top;
266
+ if (this.el.dir !== "rtl") {
267
+ return { top, left, lineHeight, clientTop };
268
+ } else {
269
+ const right = document.documentElement ? document.documentElement.clientWidth - left : 0;
270
+ return { top, right, lineHeight, clientTop };
271
+ }
272
+ }
273
+
274
+ #getElScroll() {
275
+ return { top: this.el.scrollTop, left: this.el.scrollLeft };
276
+ }
277
+
278
+ #getCursorPosition() {
279
+ return getCaretCoordinates(this.el, this.el.selectionEnd);
280
+ }
281
+
282
+ getBeforeCursor() {
283
+ return this.el.selectionStart !== this.el.selectionEnd ? null : this.el.value.substring(0, this.el.selectionEnd);
284
+ }
285
+
286
+ getAfterCursor() {
287
+ return this.el.value.substring(this.el.selectionEnd);
288
+ }
289
+
290
+ insertAtCursor(value, offset, finalOffset) {
291
+ if (this.el.selectionStart != null) {
292
+ const startPos = this.el.selectionStart;
293
+ const endPos = this.el.selectionEnd;
294
+
295
+ // Move selection to beginning of offset
296
+ this.el.selectionStart = this.el.selectionStart + offset;
297
+
298
+ // Using execCommand to support undo, but since it's officially
299
+ // 'deprecated' we need a backup solution, but it won't support undo :(
300
+ let pasted = true;
301
+ try {
302
+ if (!document.execCommand("insertText", false, value)) {
303
+ pasted = false;
304
+ }
305
+ } catch (e) {
306
+ console.error("Error caught during execCommand:", e);
307
+ pasted = false;
308
+ }
309
+
310
+ if (!pasted) {
311
+ console.error(
312
+ "execCommand unsuccessful; not supported. Adding text manually, no undo support.");
313
+ textarea.setRangeText(modifiedText, this.el.selectionStart, this.el.selectionEnd, 'end');
314
+ }
315
+
316
+ this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + (finalOffset ?? 0);
317
+ } else {
318
+ // Using execCommand to support undo, but since it's officially
319
+ // 'deprecated' we need a backup solution, but it won't support undo :(
320
+ let pasted = true;
321
+ try {
322
+ if (!document.execCommand("insertText", false, value)) {
323
+ pasted = false;
324
+ }
325
+ } catch (e) {
326
+ console.error("Error caught during execCommand:", e);
327
+ pasted = false;
328
+ }
329
+
330
+ if (!pasted) {
331
+ console.error(
332
+ "execCommand unsuccessful; not supported. Adding text manually, no undo support.");
333
+ this.el.value += value;
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ /*********************/
340
+
341
+ /**
342
+ * @typedef {{
343
+ * text: string,
344
+ * priority?: number,
345
+ * info?: Function,
346
+ * hint?: string,
347
+ * showValue?: boolean,
348
+ * caretOffset?: number
349
+ * }} AutoCompleteEntry
350
+ */
351
+ export class TextAreaAutoComplete {
352
+ static globalSeparator = "";
353
+ static enabled = true;
354
+ static insertOnTab = true;
355
+ static insertOnEnter = true;
356
+ static replacer = undefined;
357
+ static lorasEnabled = false;
358
+ static suggestionCount = 20;
359
+
360
+ /** @type {Record<string, Record<string, AutoCompleteEntry>>} */
361
+ static groups = {};
362
+ /** @type {Set<string>} */
363
+ static globalGroups = new Set();
364
+ /** @type {Record<string, AutoCompleteEntry>} */
365
+ static globalWords = {};
366
+ /** @type {Record<string, AutoCompleteEntry>} */
367
+ static globalWordsExclLoras = {};
368
+
369
+ /** @type {HTMLTextAreaElement} */
370
+ el;
371
+
372
+ /** @type {Record<string, AutoCompleteEntry>} */
373
+ overrideWords;
374
+ overrideSeparator = "";
375
+
376
+ get words() {
377
+ return this.overrideWords ?? TextAreaAutoComplete.globalWords;
378
+ }
379
+
380
+ get separator() {
381
+ return this.overrideSeparator ?? TextAreaAutoComplete.globalSeparator;
382
+ }
383
+
384
+ /**
385
+ * @param {HTMLTextAreaElement} el
386
+ */
387
+ constructor(el, words = null, separator = null) {
388
+ this.el = el;
389
+ this.helper = new TextAreaCaretHelper(el, () => app.canvas.ds.scale);
390
+ this.dropdown = $el("div.pysssss-autocomplete");
391
+ this.overrideWords = words;
392
+ this.overrideSeparator = separator;
393
+
394
+ this.#setup();
395
+ }
396
+
397
+ #setup() {
398
+ this.el.addEventListener("keydown", this.#keyDown.bind(this));
399
+ this.el.addEventListener("keypress", this.#keyPress.bind(this));
400
+ this.el.addEventListener("keyup", this.#keyUp.bind(this));
401
+ this.el.addEventListener("click", this.#hide.bind(this));
402
+ this.el.addEventListener("blur", () => setTimeout(() => this.#hide(), 150));
403
+ }
404
+
405
+ /**
406
+ * @param {KeyboardEvent} e
407
+ */
408
+ #keyDown(e) {
409
+ if (!TextAreaAutoComplete.enabled) return;
410
+
411
+ if (this.dropdown.parentElement) {
412
+ // We are visible
413
+ switch (e.key) {
414
+ case "ArrowUp":
415
+ e.preventDefault();
416
+ if (this.selected.index) {
417
+ this.#setSelected(this.currentWords[this.selected.index - 1].wordInfo);
418
+ } else {
419
+ this.#setSelected(this.currentWords[this.currentWords.length - 1].wordInfo);
420
+ }
421
+ break;
422
+ case "ArrowDown":
423
+ e.preventDefault();
424
+ if (this.selected.index === this.currentWords.length - 1) {
425
+ this.#setSelected(this.currentWords[0].wordInfo);
426
+ } else {
427
+ this.#setSelected(this.currentWords[this.selected.index + 1].wordInfo);
428
+ }
429
+ break;
430
+ case "Tab":
431
+ if (TextAreaAutoComplete.insertOnTab) {
432
+ this.#insertItem();
433
+ e.preventDefault();
434
+ }
435
+ break;
436
+ }
437
+ }
438
+ }
439
+
440
+ /**
441
+ * @param {KeyboardEvent} e
442
+ */
443
+ #keyPress(e) {
444
+ if (!TextAreaAutoComplete.enabled) return;
445
+ if (this.dropdown.parentElement) {
446
+ // We are visible
447
+ switch (e.key) {
448
+ case "Enter":
449
+ if (!e.ctrlKey) {
450
+ if (TextAreaAutoComplete.insertOnEnter) {
451
+ this.#insertItem();
452
+ e.preventDefault();
453
+ }
454
+ }
455
+ break;
456
+ }
457
+ }
458
+
459
+ if (!e.defaultPrevented) {
460
+ this.#update();
461
+ }
462
+ }
463
+
464
+ #keyUp(e) {
465
+ if (!TextAreaAutoComplete.enabled) return;
466
+ if (this.dropdown.parentElement) {
467
+ // We are visible
468
+ switch (e.key) {
469
+ case "Escape":
470
+ e.preventDefault();
471
+ this.#hide();
472
+ break;
473
+ }
474
+ } else if (e.key.length > 1 && e.key != "Delete" && e.key != "Backspace") {
475
+ return;
476
+ }
477
+ if (!e.defaultPrevented) {
478
+ this.#update();
479
+ }
480
+ }
481
+
482
+ #setSelected(item) {
483
+ if (this.selected) {
484
+ this.selected.el.classList.remove("pysssss-autocomplete-item--selected");
485
+ }
486
+
487
+ this.selected = item;
488
+ this.selected.el.classList.add("pysssss-autocomplete-item--selected");
489
+ }
490
+
491
+ #insertItem() {
492
+ if (!this.selected) return;
493
+ this.selected.el.click();
494
+ }
495
+
496
+ #getFilteredWords(term) {
497
+ term = term.toLocaleLowerCase();
498
+
499
+ const priorityMatches = [];
500
+ const prefixMatches = [];
501
+ const includesMatches = [];
502
+ for (const word of Object.keys(this.words)) {
503
+ const lowerWord = word.toLocaleLowerCase();
504
+ if (lowerWord === term) {
505
+ // Dont include exact matches
506
+ continue;
507
+ }
508
+
509
+ const pos = lowerWord.indexOf(term);
510
+ if (pos === -1) {
511
+ // No match
512
+ continue;
513
+ }
514
+
515
+ const wordInfo = this.words[word];
516
+ if (wordInfo.priority) {
517
+ priorityMatches.push({ pos, wordInfo });
518
+ } else if (pos) {
519
+ includesMatches.push({ pos, wordInfo });
520
+ } else {
521
+ prefixMatches.push({ pos, wordInfo });
522
+ }
523
+ }
524
+
525
+ priorityMatches.sort(
526
+ (a, b) =>
527
+ b.wordInfo.priority - a.wordInfo.priority ||
528
+ a.wordInfo.text.length - b.wordInfo.text.length ||
529
+ a.wordInfo.text.localeCompare(b.wordInfo.text)
530
+ );
531
+
532
+ const top = priorityMatches.length * 0.2;
533
+ return priorityMatches.slice(0, top).concat(prefixMatches, priorityMatches.slice(top), includesMatches).slice(0, TextAreaAutoComplete.suggestionCount);
534
+ }
535
+
536
+ #update() {
537
+ let before = this.helper.getBeforeCursor();
538
+ if (before?.length) {
539
+ const m = before.match(/([^,;"|{}()\n]+)$/);
540
+ if (m) {
541
+ before = m[0]
542
+ .replace(/^\s+/, "")
543
+ .replace(/\s/g, "_") || null;
544
+ } else {
545
+ before = null;
546
+ }
547
+ }
548
+
549
+ if (!before) {
550
+ this.#hide();
551
+ return;
552
+ }
553
+
554
+ this.currentWords = this.#getFilteredWords(before);
555
+ if (!this.currentWords.length) {
556
+ this.#hide();
557
+ return;
558
+ }
559
+
560
+ this.dropdown.style.display = "";
561
+
562
+ let hasSelected = false;
563
+ const items = this.currentWords.map(({ wordInfo, pos }, i) => {
564
+ const parts = [
565
+ $el("span", {
566
+ textContent: wordInfo.text.substr(0, pos),
567
+ }),
568
+ $el("span.pysssss-autocomplete-highlight", {
569
+ textContent: wordInfo.text.substr(pos, before.length),
570
+ }),
571
+ $el("span", {
572
+ textContent: wordInfo.text.substr(pos + before.length),
573
+ }),
574
+ ];
575
+
576
+ if (wordInfo.hint) {
577
+ parts.push(
578
+ $el("span.pysssss-autocomplete-pill", {
579
+ textContent: wordInfo.hint,
580
+ })
581
+ );
582
+ }
583
+
584
+ if (wordInfo.priority) {
585
+ parts.push(
586
+ $el("span.pysssss-autocomplete-pill", {
587
+ textContent: wordInfo.priority,
588
+ })
589
+ );
590
+ }
591
+
592
+ if (wordInfo.value && wordInfo.text !== wordInfo.value && wordInfo.showValue !== false) {
593
+ parts.push(
594
+ $el("span.pysssss-autocomplete-pill", {
595
+ textContent: wordInfo.value,
596
+ })
597
+ );
598
+ }
599
+
600
+ if (wordInfo.info) {
601
+ parts.push(
602
+ $el("a.pysssss-autocomplete-item-info", {
603
+ textContent: "ℹ️",
604
+ title: "View info...",
605
+ onclick: (e) => {
606
+ e.stopPropagation();
607
+ wordInfo.info();
608
+ e.preventDefault();
609
+ },
610
+ })
611
+ );
612
+ }
613
+ const item = $el(
614
+ "div.pysssss-autocomplete-item",
615
+ {
616
+ onclick: () => {
617
+ this.el.focus();
618
+ let value = wordInfo.value ?? wordInfo.text;
619
+ const use_replacer = wordInfo.use_replacer ?? true;
620
+ if (TextAreaAutoComplete.replacer && use_replacer) {
621
+ value = TextAreaAutoComplete.replacer(value);
622
+ }
623
+ value = this.#escapeParentheses(value);
624
+
625
+ const afterCursor = this.helper.getAfterCursor();
626
+ const shouldAddSeparator = !afterCursor.trim().startsWith(this.separator.trim());
627
+ this.helper.insertAtCursor(
628
+ value + (shouldAddSeparator ? this.separator : ''),
629
+ -before.length,
630
+ wordInfo.caretOffset
631
+ );
632
+ setTimeout(() => {
633
+ this.#update();
634
+ }, 150);
635
+ },
636
+ },
637
+ parts
638
+ );
639
+
640
+ if (wordInfo === this.selected) {
641
+ hasSelected = true;
642
+ }
643
+
644
+ wordInfo.index = i;
645
+ wordInfo.el = item;
646
+
647
+ return item;
648
+ });
649
+
650
+ this.#setSelected(hasSelected ? this.selected : this.currentWords[0].wordInfo);
651
+ this.dropdown.replaceChildren(...items);
652
+
653
+ if (!this.dropdown.parentElement) {
654
+ document.body.append(this.dropdown);
655
+ }
656
+
657
+ const position = this.helper.getCursorOffset();
658
+ this.dropdown.style.left = (position.left ?? 0) + "px";
659
+ this.dropdown.style.top = (position.top ?? 0) + "px";
660
+ this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px";
661
+ }
662
+
663
+ #escapeParentheses(text) {
664
+ return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
665
+ }
666
+
667
+ #hide() {
668
+ this.selected = null;
669
+ this.dropdown.remove();
670
+ }
671
+
672
+ static updateWords(id, words, addGlobal = true) {
673
+ const isUpdate = id in TextAreaAutoComplete.groups;
674
+ TextAreaAutoComplete.groups[id] = words;
675
+ if (addGlobal) {
676
+ TextAreaAutoComplete.globalGroups.add(id);
677
+ }
678
+
679
+ if (isUpdate) {
680
+ // Remerge all words
681
+ TextAreaAutoComplete.globalWords = Object.assign(
682
+ {},
683
+ ...Object.keys(TextAreaAutoComplete.groups)
684
+ .filter((k) => TextAreaAutoComplete.globalGroups.has(k))
685
+ .map((k) => TextAreaAutoComplete.groups[k])
686
+ );
687
+ } else if (addGlobal) {
688
+ // Just insert the new words
689
+ Object.assign(TextAreaAutoComplete.globalWords, words);
690
+ }
691
+ }
692
+ }
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/binding.js ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ // @ts-ignore
3
+ import { ComfyWidgets } from "../../../../scripts/widgets.js";
4
+ // @ts-ignore
5
+ import { api } from "../../../../scripts/api.js";
6
+ // @ts-ignore
7
+ import { app } from "../../../../scripts/app.js";
8
+
9
+ const PathHelper = {
10
+ get(obj, path) {
11
+ if (typeof path !== "string") {
12
+ // Hardcoded value
13
+ return path;
14
+ }
15
+
16
+ if (path[0] === '"' && path[path.length - 1] === '"') {
17
+ // Hardcoded string
18
+ return JSON.parse(path);
19
+ }
20
+
21
+ // Evaluate the path
22
+ path = path.split(".").filter(Boolean);
23
+ for (const p of path) {
24
+ const k = isNaN(+p) ? p : +p;
25
+ obj = obj[k];
26
+ }
27
+
28
+ return obj;
29
+ },
30
+ set(obj, path, value) {
31
+ // https://stackoverflow.com/a/54733755
32
+ if (Object(obj) !== obj) return obj; // When obj is not an object
33
+ // If not yet an array, get the keys from the string-path
34
+ if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
35
+ path.slice(0, -1).reduce(
36
+ (
37
+ a,
38
+ c,
39
+ i // Iterate all of them except the last one
40
+ ) =>
41
+ Object(a[c]) === a[c] // Does the key exist and is its value an object?
42
+ ? // Yes: then follow that path
43
+ a[c]
44
+ : // No: create the key. Is the next key a potential array-index?
45
+ (a[c] =
46
+ Math.abs(path[i + 1]) >> 0 === +path[i + 1]
47
+ ? [] // Yes: assign a new array object
48
+ : {}), // No: assign a new plain object
49
+ obj
50
+ )[path[path.length - 1]] = value; // Finally assign the value to the last key
51
+ return obj; // Return the top-level object to allow chaining
52
+ },
53
+ };
54
+
55
+ /***
56
+ @typedef { {
57
+ left: string;
58
+ op: "eq" | "ne",
59
+ right: string
60
+ } } IfCondition
61
+
62
+ @typedef { {
63
+ type: "if",
64
+ condition: Array<IfCondition>,
65
+ true?: Array<BindingCallback>,
66
+ false?: Array<BindingCallback>
67
+ } } IfCallback
68
+
69
+ @typedef { {
70
+ type: "fetch",
71
+ url: string,
72
+ then: Array<BindingCallback>
73
+ } } FetchCallback
74
+
75
+ @typedef { {
76
+ type: "set",
77
+ target: string,
78
+ value: string
79
+ } } SetCallback
80
+
81
+ @typedef { {
82
+ type: "validate-combo",
83
+ } } ValidateComboCallback
84
+
85
+ @typedef { IfCallback | FetchCallback | SetCallback | ValidateComboCallback } BindingCallback
86
+
87
+ @typedef { {
88
+ source: string,
89
+ callback: Array<BindingCallback>
90
+ } } Binding
91
+ ***/
92
+
93
+ /**
94
+ * @param {IfCondition} condition
95
+ */
96
+ function evaluateCondition(condition, state) {
97
+ const left = PathHelper.get(state, condition.left);
98
+ const right = PathHelper.get(state, condition.right);
99
+
100
+ let r;
101
+ if (condition.op === "eq") {
102
+ r = left === right;
103
+ } else {
104
+ r = left !== right;
105
+ }
106
+
107
+ return r;
108
+ }
109
+
110
+ /**
111
+ * @type { Record<BindingCallback["type"], (cb: any, state: Record<string, any>) => Promise<void>> }
112
+ */
113
+ const callbacks = {
114
+ /**
115
+ * @param {IfCallback} cb
116
+ */
117
+ async if(cb, state) {
118
+ // For now only support ANDs
119
+ let success = true;
120
+ for (const condition of cb.condition) {
121
+ const r = evaluateCondition(condition, state);
122
+ if (!r) {
123
+ success = false;
124
+ break;
125
+ }
126
+ }
127
+
128
+ for (const m of cb[success + ""] ?? []) {
129
+ await invokeCallback(m, state);
130
+ }
131
+ },
132
+ /**
133
+ * @param {FetchCallback} cb
134
+ */
135
+ async fetch(cb, state) {
136
+ const url = cb.url.replace(/\{([^\}]+)\}/g, (m, v) => {
137
+ return PathHelper.get(state, v);
138
+ });
139
+ const res = await (await api.fetchApi(url)).json();
140
+ state["$result"] = res;
141
+ for (const m of cb.then) {
142
+ await invokeCallback(m, state);
143
+ }
144
+ },
145
+ /**
146
+ * @param {SetCallback} cb
147
+ */
148
+ async set(cb, state) {
149
+ const value = PathHelper.get(state, cb.value);
150
+ PathHelper.set(state, cb.target, value);
151
+ },
152
+ async "validate-combo"(cb, state) {
153
+ const w = state["$this"];
154
+ const valid = w.options.values.includes(w.value);
155
+ if (!valid) {
156
+ w.value = w.options.values[0];
157
+ }
158
+ },
159
+ };
160
+
161
+ async function invokeCallback(callback, state) {
162
+ if (callback.type in callbacks) {
163
+ // @ts-ignore
164
+ await callbacks[callback.type](callback, state);
165
+ } else {
166
+ console.warn(
167
+ "%c[🐍 pysssss]",
168
+ "color: limegreen",
169
+ `[binding ${state.$node.comfyClass}.${state.$this.name}]`,
170
+ "unsupported binding callback type:",
171
+ callback.type
172
+ );
173
+ }
174
+ }
175
+
176
+ app.registerExtension({
177
+ name: "pysssss.Binding",
178
+ beforeRegisterNodeDef(node, nodeData) {
179
+ const hasBinding = (v) => {
180
+ if (!v) return false;
181
+ return Object.values(v).find((c) => c[1]?.["pysssss.binding"]);
182
+ };
183
+ const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional };
184
+ if (hasBinding(inputs)) {
185
+ const onAdded = node.prototype.onAdded;
186
+ node.prototype.onAdded = function () {
187
+ const r = onAdded?.apply(this, arguments);
188
+
189
+ for (const widget of this.widgets || []) {
190
+ const bindings = inputs[widget.name][1]?.["pysssss.binding"];
191
+ if (!bindings) continue;
192
+
193
+ for (const binding of bindings) {
194
+ /**
195
+ * @type {import("../../../../../web/types/litegraph.d.ts").IWidget}
196
+ */
197
+ const source = this.widgets.find((w) => w.name === binding.source);
198
+ if (!source) {
199
+ console.warn(
200
+ "%c[🐍 pysssss]",
201
+ "color: limegreen",
202
+ `[binding ${node.comfyClass}.${widget.name}]`,
203
+ "unable to find source binding widget:",
204
+ binding.source,
205
+ binding
206
+ );
207
+ continue;
208
+ }
209
+
210
+ let lastValue;
211
+ async function valueChanged() {
212
+ const state = {
213
+ $this: widget,
214
+ $source: source,
215
+ $node: node,
216
+ };
217
+
218
+ for (const callback of binding.callback) {
219
+ await invokeCallback(callback, state);
220
+ }
221
+
222
+ app.graph.setDirtyCanvas(true, false);
223
+ }
224
+
225
+ const cb = source.callback;
226
+ source.callback = function () {
227
+ const v = cb?.apply(this, arguments) ?? source.value;
228
+ if (v !== lastValue) {
229
+ lastValue = v;
230
+ valueChanged();
231
+ }
232
+ return v;
233
+ };
234
+
235
+ lastValue = source.value;
236
+ valueChanged();
237
+ }
238
+ }
239
+
240
+ return r;
241
+ };
242
+ }
243
+ },
244
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/lightbox.css ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .pysssss-lightbox {
2
+ width: 100vw;
3
+ height: 100vh;
4
+ position: fixed;
5
+ top: 0;
6
+ left: 0;
7
+ z-index: 1001;
8
+ background: rgba(0, 0, 0, 0.6);
9
+ display: flex;
10
+ align-items: center;
11
+ transition: opacity 0.2s;
12
+ }
13
+
14
+ .pysssss-lightbox-prev,
15
+ .pysssss-lightbox-next {
16
+ height: 60px;
17
+ display: flex;
18
+ align-items: center;
19
+ }
20
+
21
+ .pysssss-lightbox-prev:after,
22
+ .pysssss-lightbox-next:after {
23
+ border-style: solid;
24
+ border-width: 0.25em 0.25em 0 0;
25
+ display: inline-block;
26
+ height: 0.45em;
27
+ left: 0.15em;
28
+ position: relative;
29
+ top: 0.15em;
30
+ transform: rotate(-135deg) scale(0.75);
31
+ vertical-align: top;
32
+ width: 0.45em;
33
+ padding: 10px;
34
+ font-size: 20px;
35
+ margin: 0 10px 0 20px;
36
+ transition: color 0.2s;
37
+ flex-shrink: 0;
38
+ content: "";
39
+ }
40
+
41
+ .pysssss-lightbox-next:after {
42
+ transform: rotate(45deg) scale(0.75);
43
+ margin: 0 20px 0 0px;
44
+ }
45
+
46
+ .pysssss-lightbox-main {
47
+ display: grid;
48
+ flex: auto;
49
+ place-content: center;
50
+ text-align: center;
51
+ }
52
+
53
+ .pysssss-lightbox-link {
54
+ display: flex;
55
+ justify-content: center;
56
+ align-items: center;
57
+ position: relative;
58
+ }
59
+
60
+ .pysssss-lightbox .lds-ring {
61
+ position: absolute;
62
+ left: 50%;
63
+ top: 50%;
64
+ transform: translate(-50%, -50%);
65
+ }
66
+
67
+ .pysssss-lightbox-img {
68
+ max-height: 90vh;
69
+ max-width: calc(100vw - 130px);
70
+ height: auto;
71
+ object-fit: contain;
72
+ border: 3px solid white;
73
+ border-radius: 4px;
74
+ transition: opacity 0.2s;
75
+ user-select: none;
76
+ }
77
+
78
+ .pysssss-lightbox-img:hover {
79
+ border-color: dodgerblue;
80
+ }
81
+
82
+ .pysssss-lightbox-close {
83
+ font-size: 80px;
84
+ line-height: 1ch;
85
+ height: 1ch;
86
+ width: 1ch;
87
+ position: absolute;
88
+ right: 10px;
89
+ top: 10px;
90
+ padding: 5px;
91
+ }
92
+
93
+ .pysssss-lightbox-close:after {
94
+ content: "\00d7";
95
+ }
96
+
97
+ .pysssss-lightbox-close:hover,
98
+ .pysssss-lightbox-prev:hover,
99
+ .pysssss-lightbox-next:hover {
100
+ color: dodgerblue;
101
+ cursor: pointer;
102
+ }
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/lightbox.js ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $el } from "../../../../scripts/ui.js";
2
+ import { addStylesheet, getUrl, loadImage } from "./utils.js";
3
+ import { createSpinner } from "./spinner.js";
4
+
5
+ addStylesheet(getUrl("lightbox.css", import.meta.url));
6
+
7
+ const $$el = (tag, name, ...args) => {
8
+ if (name) name = "-" + name;
9
+ return $el(tag + ".pysssss-lightbox" + name, ...args);
10
+ };
11
+
12
+ const ani = async (a, t, b) => {
13
+ a();
14
+ await new Promise((r) => setTimeout(r, t));
15
+ b();
16
+ };
17
+
18
+ export class Lightbox {
19
+ constructor() {
20
+ this.el = $$el("div", "", {
21
+ parent: document.body,
22
+ onclick: (e) => {
23
+ e.stopImmediatePropagation();
24
+ this.close();
25
+ },
26
+ style: {
27
+ display: "none",
28
+ opacity: 0,
29
+ },
30
+ });
31
+ this.closeBtn = $$el("div", "close", {
32
+ parent: this.el,
33
+ });
34
+ this.prev = $$el("div", "prev", {
35
+ parent: this.el,
36
+ onclick: (e) => {
37
+ this.update(-1);
38
+ e.stopImmediatePropagation();
39
+ },
40
+ });
41
+ this.main = $$el("div", "main", {
42
+ parent: this.el,
43
+ });
44
+ this.next = $$el("div", "next", {
45
+ parent: this.el,
46
+ onclick: (e) => {
47
+ this.update(1);
48
+ e.stopImmediatePropagation();
49
+ },
50
+ });
51
+ this.link = $$el("a", "link", {
52
+ parent: this.main,
53
+ target: "_blank",
54
+ });
55
+ this.spinner = createSpinner();
56
+ this.link.appendChild(this.spinner);
57
+ this.img = $$el("img", "img", {
58
+ style: {
59
+ opacity: 0,
60
+ },
61
+ parent: this.link,
62
+ onclick: (e) => {
63
+ e.stopImmediatePropagation();
64
+ },
65
+ onwheel: (e) => {
66
+ if (!(e instanceof WheelEvent) || e.ctrlKey) {
67
+ return;
68
+ }
69
+ const direction = Math.sign(e.deltaY);
70
+ this.update(direction);
71
+ },
72
+ });
73
+ }
74
+
75
+ close() {
76
+ ani(
77
+ () => (this.el.style.opacity = 0),
78
+ 200,
79
+ () => (this.el.style.display = "none")
80
+ );
81
+ }
82
+
83
+ async show(images, index) {
84
+ this.images = images;
85
+ this.index = index || 0;
86
+ await this.update(0);
87
+ }
88
+
89
+ async update(shift) {
90
+ if (shift < 0 && this.index <= 0) {
91
+ return;
92
+ }
93
+ if (shift > 0 && this.index >= this.images.length - 1) {
94
+ return;
95
+ }
96
+ this.index += shift;
97
+
98
+ this.prev.style.visibility = this.index ? "unset" : "hidden";
99
+ this.next.style.visibility = this.index === this.images.length - 1 ? "hidden" : "unset";
100
+
101
+ const img = this.images[this.index];
102
+ this.el.style.display = "flex";
103
+ this.el.clientWidth; // Force a reflow
104
+ this.el.style.opacity = 1;
105
+ this.img.style.opacity = 0;
106
+ this.spinner.style.display = "inline-block";
107
+ try {
108
+ await loadImage(img);
109
+ } catch (err) {
110
+ console.error('failed to load image', img, err);
111
+ }
112
+ this.spinner.style.display = "none";
113
+ this.link.href = img;
114
+ this.img.src = img;
115
+ this.img.style.opacity = 1;
116
+ }
117
+
118
+ async updateWithNewImage(img, feedDirection) {
119
+ // No-op if lightbox is not open
120
+ if (this.el.style.display === "none" || this.el.style.opacity === "0") return;
121
+
122
+ // Ensure currently shown image does not change
123
+ const [method, shift] = feedDirection === "newest first" ? ["unshift", 1] : ["push", 0];
124
+ this.images[method](img);
125
+ await this.update(shift);
126
+ }
127
+ }
128
+
129
+ export const lightbox = new Lightbox();
130
+
131
+ addEventListener('keydown', (event) => {
132
+ if (lightbox.el.style.display === 'none') {
133
+ return;
134
+ }
135
+ const { key } = event;
136
+ switch (key) {
137
+ case 'ArrowLeft':
138
+ case 'a':
139
+ lightbox.update(-1);
140
+ break;
141
+ case 'ArrowRight':
142
+ case 'd':
143
+ lightbox.update(1);
144
+ break;
145
+ case 'Escape':
146
+ lightbox.close();
147
+ break;
148
+ }
149
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/modelInfoDialog.css ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .pysssss-model-info {
2
+ color: white;
3
+ font-family: sans-serif;
4
+ max-width: 90vw;
5
+ }
6
+ .pysssss-model-content {
7
+ display: flex;
8
+ flex-direction: column;
9
+ overflow: hidden;
10
+ }
11
+ .pysssss-model-info h2 {
12
+ text-align: center;
13
+ margin: 0 0 10px 0;
14
+ }
15
+ .pysssss-model-info p {
16
+ margin: 5px 0;
17
+ }
18
+ .pysssss-model-info a {
19
+ color: dodgerblue;
20
+ }
21
+ .pysssss-model-info a:hover {
22
+ text-decoration: underline;
23
+ }
24
+ .pysssss-model-tags-list {
25
+ display: flex;
26
+ flex-wrap: wrap;
27
+ list-style: none;
28
+ gap: 10px;
29
+ max-height: 200px;
30
+ overflow: auto;
31
+ margin: 10px 0;
32
+ padding: 0;
33
+ }
34
+ .pysssss-model-tag {
35
+ background-color: rgb(128, 213, 247);
36
+ color: #000;
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 5px;
40
+ border-radius: 5px;
41
+ padding: 2px 5px;
42
+ cursor: pointer;
43
+ }
44
+ .pysssss-model-tag--selected span::before {
45
+ content: "✅";
46
+ position: absolute;
47
+ background-color: dodgerblue;
48
+ left: 0;
49
+ top: 0;
50
+ right: 0;
51
+ bottom: 0;
52
+ text-align: center;
53
+ }
54
+ .pysssss-model-tag:hover {
55
+ outline: 2px solid dodgerblue;
56
+ }
57
+ .pysssss-model-tag p {
58
+ margin: 0;
59
+ }
60
+ .pysssss-model-tag span {
61
+ text-align: center;
62
+ border-radius: 5px;
63
+ background-color: dodgerblue;
64
+ color: #fff;
65
+ padding: 2px;
66
+ position: relative;
67
+ min-width: 20px;
68
+ overflow: hidden;
69
+ }
70
+
71
+ .pysssss-model-metadata .comfy-modal-content {
72
+ max-width: 100%;
73
+ }
74
+ .pysssss-model-metadata label {
75
+ margin-right: 1ch;
76
+ color: #ccc;
77
+ }
78
+
79
+ .pysssss-model-metadata span {
80
+ color: dodgerblue;
81
+ }
82
+
83
+ .pysssss-preview {
84
+ max-width: 50%;
85
+ margin-left: 10px;
86
+ position: relative;
87
+ }
88
+ .pysssss-preview img {
89
+ max-height: 300px;
90
+ }
91
+ .pysssss-preview button {
92
+ position: absolute;
93
+ font-size: 12px;
94
+ bottom: 10px;
95
+ right: 10px;
96
+ }
97
+ .pysssss-preview button+button {
98
+ bottom: 34px;
99
+ }
100
+
101
+ .pysssss-preview button.pysssss-preview-nav {
102
+ bottom: unset;
103
+ right: 30px;
104
+ top: 10px;
105
+ font-size: 14px;
106
+ line-height: 14px;
107
+ }
108
+
109
+ .pysssss-preview button.pysssss-preview-nav+.pysssss-preview-nav {
110
+ right: 10px;
111
+ }
112
+ .pysssss-model-notes {
113
+ background-color: rgba(0, 0, 0, 0.25);
114
+ padding: 5px;
115
+ margin-top: 5px;
116
+ }
117
+ .pysssss-model-notes:empty {
118
+ display: none;
119
+ }
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/modelInfoDialog.js ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $el, ComfyDialog } from "../../../../scripts/ui.js";
2
+ import { api } from "../../../../scripts/api.js";
3
+ import { addStylesheet } from "./utils.js";
4
+
5
+ addStylesheet(import.meta.url);
6
+
7
+ class MetadataDialog extends ComfyDialog {
8
+ constructor() {
9
+ super();
10
+
11
+ this.element.classList.add("pysssss-model-metadata");
12
+ }
13
+ show(metadata) {
14
+ super.show(
15
+ $el(
16
+ "div",
17
+ Object.keys(metadata).map((k) =>
18
+ $el("div", [
19
+ $el("label", { textContent: k }),
20
+ $el("span", { textContent: typeof metadata[k] === "object" ? JSON.stringify(metadata[k]) : metadata[k] }),
21
+ ])
22
+ )
23
+ )
24
+ );
25
+ }
26
+ }
27
+
28
+ export class ModelInfoDialog extends ComfyDialog {
29
+ constructor(name, node) {
30
+ super();
31
+ this.name = name;
32
+ this.node = node;
33
+ this.element.classList.add("pysssss-model-info");
34
+ }
35
+
36
+ get customNotes() {
37
+ return this.metadata["pysssss.notes"];
38
+ }
39
+
40
+ set customNotes(v) {
41
+ this.metadata["pysssss.notes"] = v;
42
+ }
43
+
44
+ get hash() {
45
+ return this.metadata["pysssss.sha256"];
46
+ }
47
+
48
+ async show(type, value) {
49
+ this.type = type;
50
+
51
+ const req = api.fetchApi("/pysssss/metadata/" + encodeURIComponent(`${type}/${value}`));
52
+ this.info = $el("div", { style: { flex: "auto" } });
53
+ this.img = $el("img", { style: { display: "none" } });
54
+ this.imgWrapper = $el("div.pysssss-preview", [this.img]);
55
+ this.main = $el("main", { style: { display: "flex" } }, [this.info, this.imgWrapper]);
56
+ this.content = $el("div.pysssss-model-content", [$el("h2", { textContent: this.name }), this.main]);
57
+
58
+ const loading = $el("div", { textContent: "ℹ️ Loading...", parent: this.content });
59
+
60
+ super.show(this.content);
61
+
62
+ this.metadata = await (await req).json();
63
+ this.viewMetadata.style.cursor = this.viewMetadata.style.opacity = "";
64
+ this.viewMetadata.removeAttribute("disabled");
65
+
66
+ loading.remove();
67
+ this.addInfo();
68
+ }
69
+
70
+ createButtons() {
71
+ const btns = super.createButtons();
72
+ this.viewMetadata = $el("button", {
73
+ type: "button",
74
+ textContent: "View raw metadata",
75
+ disabled: "disabled",
76
+ style: {
77
+ opacity: 0.5,
78
+ cursor: "not-allowed",
79
+ },
80
+ onclick: (e) => {
81
+ if (this.metadata) {
82
+ new MetadataDialog().show(this.metadata);
83
+ }
84
+ },
85
+ });
86
+
87
+ btns.unshift(this.viewMetadata);
88
+ return btns;
89
+ }
90
+
91
+ getNoteInfo() {
92
+ function parseNote() {
93
+ if (!this.customNotes) return [];
94
+
95
+ let notes = [];
96
+ // Extract links from notes
97
+ const r = new RegExp("(\\bhttps?:\\/\\/[^\\s]+)", "g");
98
+ let end = 0;
99
+ let m;
100
+ do {
101
+ m = r.exec(this.customNotes);
102
+ let pos;
103
+ let fin = 0;
104
+ if (m) {
105
+ pos = m.index;
106
+ fin = m.index + m[0].length;
107
+ } else {
108
+ pos = this.customNotes.length;
109
+ }
110
+
111
+ let pre = this.customNotes.substring(end, pos);
112
+ if (pre) {
113
+ pre = pre.replaceAll("\n", "<br>");
114
+ notes.push(
115
+ $el("span", {
116
+ innerHTML: pre,
117
+ })
118
+ );
119
+ }
120
+ if (m) {
121
+ notes.push(
122
+ $el("a", {
123
+ href: m[0],
124
+ textContent: m[0],
125
+ target: "_blank",
126
+ })
127
+ );
128
+ }
129
+
130
+ end = fin;
131
+ } while (m);
132
+ return notes;
133
+ }
134
+
135
+ let textarea;
136
+ let notesContainer;
137
+ const editText = "✏️ Edit";
138
+ const edit = $el("a", {
139
+ textContent: editText,
140
+ href: "#",
141
+ style: {
142
+ float: "right",
143
+ color: "greenyellow",
144
+ textDecoration: "none",
145
+ },
146
+ onclick: async (e) => {
147
+ e.preventDefault();
148
+
149
+ if (textarea) {
150
+ this.customNotes = textarea.value;
151
+
152
+ const resp = await api.fetchApi("/pysssss/metadata/notes/" + encodeURIComponent(`${this.type}/${this.name}`), {
153
+ method: "POST",
154
+ body: this.customNotes,
155
+ });
156
+
157
+ if (resp.status !== 200) {
158
+ console.error(resp);
159
+ alert(`Error saving notes (${req.status}) ${req.statusText}`);
160
+ return;
161
+ }
162
+
163
+ e.target.textContent = editText;
164
+ textarea.remove();
165
+ textarea = null;
166
+
167
+ notesContainer.replaceChildren(...parseNote.call(this));
168
+ this.node?.["pysssss.updateExamples"]?.();
169
+ } else {
170
+ e.target.textContent = "💾 Save";
171
+ textarea = $el("textarea", {
172
+ style: {
173
+ width: "100%",
174
+ minWidth: "200px",
175
+ minHeight: "50px",
176
+ },
177
+ textContent: this.customNotes,
178
+ });
179
+ e.target.after(textarea);
180
+ notesContainer.replaceChildren();
181
+ textarea.style.height = Math.min(textarea.scrollHeight, 300) + "px";
182
+ }
183
+ },
184
+ });
185
+
186
+ notesContainer = $el("div.pysssss-model-notes", parseNote.call(this));
187
+ return $el(
188
+ "div",
189
+ {
190
+ style: { display: "contents" },
191
+ },
192
+ [edit, notesContainer]
193
+ );
194
+ }
195
+
196
+ addInfo() {
197
+ const usageHint = this.metadata["modelspec.usage_hint"];
198
+ if (usageHint) {
199
+ this.addInfoEntry("Usage Hint", usageHint);
200
+ }
201
+ this.addInfoEntry("Notes", this.getNoteInfo());
202
+ }
203
+
204
+ addInfoEntry(name, value) {
205
+ return $el(
206
+ "p",
207
+ {
208
+ parent: this.info,
209
+ },
210
+ [
211
+ typeof name === "string" ? $el("label", { textContent: name + ": " }) : name,
212
+ typeof value === "string" ? $el("span", { textContent: value }) : value,
213
+ ]
214
+ );
215
+ }
216
+
217
+ async getCivitaiDetails() {
218
+ const req = await fetch("https://civitai.com/api/v1/model-versions/by-hash/" + this.hash);
219
+ if (req.status === 200) {
220
+ return await req.json();
221
+ } else if (req.status === 404) {
222
+ throw new Error("Model not found");
223
+ } else {
224
+ throw new Error(`Error loading info (${req.status}) ${req.statusText}`);
225
+ }
226
+ }
227
+
228
+ addCivitaiInfo() {
229
+ const promise = this.getCivitaiDetails();
230
+ const content = $el("span", { textContent: "ℹ️ Loading..." });
231
+
232
+ this.addInfoEntry(
233
+ $el("label", [
234
+ $el("img", {
235
+ style: {
236
+ width: "18px",
237
+ position: "relative",
238
+ top: "3px",
239
+ margin: "0 5px 0 0",
240
+ },
241
+ src: "https://civitai.com/favicon.ico",
242
+ }),
243
+ $el("span", { textContent: "Civitai: " }),
244
+ ]),
245
+ content
246
+ );
247
+
248
+ return promise
249
+ .then((info) => {
250
+ content.replaceChildren(
251
+ $el("a", {
252
+ href: "https://civitai.com/models/" + info.modelId,
253
+ textContent: "View " + info.model.name,
254
+ target: "_blank",
255
+ })
256
+ );
257
+
258
+ const allPreviews = info.images?.filter((i) => i.type === "image");
259
+ const previews = allPreviews?.filter((i) => i.nsfwLevel <= ModelInfoDialog.nsfwLevel);
260
+ if (previews?.length) {
261
+ let previewIndex = 0;
262
+ let preview;
263
+ const updatePreview = () => {
264
+ preview = previews[previewIndex];
265
+ this.img.src = preview.url;
266
+ };
267
+
268
+ updatePreview();
269
+ this.img.style.display = "";
270
+
271
+ this.img.title = `${previews.length} previews.`;
272
+ if (allPreviews.length !== previews.length) {
273
+ this.img.title += ` ${allPreviews.length - previews.length} images hidden due to NSFW level.`;
274
+ }
275
+
276
+ this.imgSave = $el("button", {
277
+ textContent: "Use as preview",
278
+ parent: this.imgWrapper,
279
+ onclick: async () => {
280
+ // Convert the preview to a blob
281
+ const blob = await (await fetch(this.img.src)).blob();
282
+
283
+ // Store it in temp
284
+ const name = "temp_preview." + new URL(this.img.src).pathname.split(".")[1];
285
+ const body = new FormData();
286
+ body.append("image", new File([blob], name));
287
+ body.append("overwrite", "true");
288
+ body.append("type", "temp");
289
+
290
+ const resp = await api.fetchApi("/upload/image", {
291
+ method: "POST",
292
+ body,
293
+ });
294
+
295
+ if (resp.status !== 200) {
296
+ console.error(resp);
297
+ alert(`Error saving preview (${req.status}) ${req.statusText}`);
298
+ return;
299
+ }
300
+
301
+ // Use as preview
302
+ await api.fetchApi("/pysssss/save/" + encodeURIComponent(`${this.type}/${this.name}`), {
303
+ method: "POST",
304
+ body: JSON.stringify({
305
+ filename: name,
306
+ type: "temp",
307
+ }),
308
+ headers: {
309
+ "content-type": "application/json",
310
+ },
311
+ });
312
+ app.refreshComboInNodes();
313
+ },
314
+ });
315
+
316
+ $el("button", {
317
+ textContent: "Show metadata",
318
+ parent: this.imgWrapper,
319
+ onclick: async () => {
320
+ if (preview.meta && Object.keys(preview.meta).length) {
321
+ new MetadataDialog().show(preview.meta);
322
+ } else {
323
+ alert("No image metadata found");
324
+ }
325
+ },
326
+ });
327
+
328
+ const addNavButton = (icon, direction) => {
329
+ $el("button.pysssss-preview-nav", {
330
+ textContent: icon,
331
+ parent: this.imgWrapper,
332
+ onclick: async () => {
333
+ previewIndex += direction;
334
+ if (previewIndex < 0) {
335
+ previewIndex = previews.length - 1;
336
+ } else if (previewIndex >= previews.length) {
337
+ previewIndex = 0;
338
+ }
339
+ updatePreview();
340
+ },
341
+ });
342
+ };
343
+
344
+ if (previews.length > 1) {
345
+ addNavButton("‹", -1);
346
+ addNavButton("›", 1);
347
+ }
348
+ } else if (info.images?.length) {
349
+ $el("span", { style: { opacity: 0.6 }, textContent: "⚠️ All images hidden due to NSFW level setting.", parent: this.imgWrapper });
350
+ }
351
+
352
+ return info;
353
+ })
354
+ .catch((err) => {
355
+ content.textContent = "⚠️ " + err.message;
356
+ });
357
+ }
358
+ }
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/spinner.css ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .pysssss-lds-ring {
2
+ display: inline-block;
3
+ position: absolute;
4
+ width: 80px;
5
+ height: 80px;
6
+ }
7
+ .pysssss-lds-ring div {
8
+ box-sizing: border-box;
9
+ display: block;
10
+ position: absolute;
11
+ width: 64px;
12
+ height: 64px;
13
+ margin: 8px;
14
+ border: 5px solid #fff;
15
+ border-radius: 50%;
16
+ animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
17
+ border-color: #fff transparent transparent transparent;
18
+ }
19
+ .pysssss-lds-ring div:nth-child(1) {
20
+ animation-delay: -0.45s;
21
+ }
22
+ .pysssss-lds-ring div:nth-child(2) {
23
+ animation-delay: -0.3s;
24
+ }
25
+ .pysssss-lds-ring div:nth-child(3) {
26
+ animation-delay: -0.15s;
27
+ }
28
+ @keyframes lds-ring {
29
+ 0% {
30
+ transform: rotate(0deg);
31
+ }
32
+ 100% {
33
+ transform: rotate(360deg);
34
+ }
35
+ }
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/spinner.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { addStylesheet } from "./utils.js";
2
+
3
+ addStylesheet(import.meta.url);
4
+
5
+ export function createSpinner() {
6
+ const div = document.createElement("div");
7
+ div.innerHTML = `<div class="pysssss-lds-ring"><div></div><div></div><div></div><div></div></div>`;
8
+ return div.firstElementChild;
9
+ }
custom_nodes/ComfyUI-Custom-Scripts/web/js/common/utils.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $el } from "../../../../scripts/ui.js";
2
+
3
+ export function addStylesheet(url) {
4
+ if (url.endsWith(".js")) {
5
+ url = url.substr(0, url.length - 2) + "css";
6
+ }
7
+ $el("link", {
8
+ parent: document.head,
9
+ rel: "stylesheet",
10
+ type: "text/css",
11
+ href: url.startsWith("http") ? url : getUrl(url),
12
+ });
13
+ }
14
+
15
+ export function getUrl(path, baseUrl) {
16
+ if (baseUrl) {
17
+ return new URL(path, baseUrl).toString();
18
+ } else {
19
+ return new URL("../" + path, import.meta.url).toString();
20
+ }
21
+ }
22
+
23
+ export async function loadImage(url) {
24
+ return new Promise((res, rej) => {
25
+ const img = new Image();
26
+ img.onload = res;
27
+ img.onerror = rej;
28
+ img.src = url;
29
+ });
30
+ }
custom_nodes/ComfyUI-Custom-Scripts/web/js/contextMenuHook.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+ app.registerExtension({
3
+ name: "pysssss.ContextMenuHook",
4
+ init() {
5
+ const getOrSet = (target, name, create) => {
6
+ if (name in target) return target[name];
7
+ return (target[name] = create());
8
+ };
9
+ const symbol = getOrSet(window, "__pysssss__", () => Symbol("__pysssss__"));
10
+ const store = getOrSet(window, symbol, () => ({}));
11
+ const contextMenuHook = getOrSet(store, "contextMenuHook", () => ({}));
12
+ for (const e of ["ctor", "preAddItem", "addItem"]) {
13
+ if (!contextMenuHook[e]) {
14
+ contextMenuHook[e] = [];
15
+ }
16
+ }
17
+
18
+ // Big ol' hack to get allow customizing the context menu
19
+ // Replace the addItem function with our own that wraps the context of "this" with a proxy
20
+ // That proxy then replaces the constructor with another proxy
21
+ // That proxy then calls the custom ContextMenu that supports filters
22
+ const ctorProxy = new Proxy(LiteGraph.ContextMenu, {
23
+ construct(target, args) {
24
+ return new LiteGraph.ContextMenu(...args);
25
+ },
26
+ });
27
+
28
+ function triggerCallbacks(name, getArgs, handler) {
29
+ const callbacks = contextMenuHook[name];
30
+ if (callbacks && callbacks instanceof Array) {
31
+ for (const cb of callbacks) {
32
+ const r = cb(...getArgs());
33
+ handler?.call(this, r);
34
+ }
35
+ } else {
36
+ console.warn("[pysssss 🐍]", `invalid ${name} callbacks`, callbacks, name in contextMenuHook);
37
+ }
38
+ }
39
+
40
+ const addItem = LiteGraph.ContextMenu.prototype.addItem;
41
+ LiteGraph.ContextMenu.prototype.addItem = function () {
42
+ const proxy = new Proxy(this, {
43
+ get(target, prop) {
44
+ if (prop === "constructor") {
45
+ return ctorProxy;
46
+ }
47
+ return target[prop];
48
+ },
49
+ });
50
+ proxy.__target__ = this;
51
+
52
+ let el;
53
+ let args = arguments;
54
+ triggerCallbacks(
55
+ "preAddItem",
56
+ () => [el, this, args],
57
+ (r) => {
58
+ if (r !== undefined) el = r;
59
+ }
60
+ );
61
+
62
+ if (el === undefined) {
63
+ el = addItem.apply(proxy, arguments);
64
+ }
65
+
66
+ triggerCallbacks(
67
+ "addItem",
68
+ () => [el, this, args],
69
+ (r) => {
70
+ if (r !== undefined) el = r;
71
+ }
72
+ );
73
+ return el;
74
+ };
75
+
76
+ // We also need to patch the ContextMenu constructor to unwrap the parent else it fails a LiteGraph type check
77
+ const ctxMenu = LiteGraph.ContextMenu;
78
+ LiteGraph.ContextMenu = function (values, options) {
79
+ if (options?.parentMenu) {
80
+ if (options.parentMenu.__target__) {
81
+ options.parentMenu = options.parentMenu.__target__;
82
+ }
83
+ }
84
+
85
+ triggerCallbacks("ctor", () => [values, options]);
86
+ return ctxMenu.call(this, values, options);
87
+ };
88
+ LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
89
+ },
90
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/customColors.js ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+ import { $el } from "../../../scripts/ui.js";
3
+
4
+ const colorShade = (col, amt) => {
5
+ col = col.replace(/^#/, "");
6
+ if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2];
7
+
8
+ let [r, g, b] = col.match(/.{2}/g);
9
+ [r, g, b] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt];
10
+
11
+ r = Math.max(Math.min(255, r), 0).toString(16);
12
+ g = Math.max(Math.min(255, g), 0).toString(16);
13
+ b = Math.max(Math.min(255, b), 0).toString(16);
14
+
15
+ const rr = (r.length < 2 ? "0" : "") + r;
16
+ const gg = (g.length < 2 ? "0" : "") + g;
17
+ const bb = (b.length < 2 ? "0" : "") + b;
18
+
19
+ return `#${rr}${gg}${bb}`;
20
+ };
21
+
22
+ app.registerExtension({
23
+ name: "pysssss.CustomColors",
24
+ setup() {
25
+ let picker;
26
+ let activeNode;
27
+ const onMenuNodeColors = LGraphCanvas.onMenuNodeColors;
28
+ LGraphCanvas.onMenuNodeColors = function (value, options, e, menu, node) {
29
+ const r = onMenuNodeColors.apply(this, arguments);
30
+ requestAnimationFrame(() => {
31
+ const menus = document.querySelectorAll(".litecontextmenu");
32
+ for (let i = menus.length - 1; i >= 0; i--) {
33
+ if (menus[i].firstElementChild.textContent.includes("No color") || menus[i].firstElementChild.value?.content?.includes("No color")) {
34
+ $el(
35
+ "div.litemenu-entry.submenu",
36
+ {
37
+ parent: menus[i],
38
+ $: (el) => {
39
+ el.onclick = () => {
40
+ LiteGraph.closeAllContextMenus();
41
+ if (!picker) {
42
+ picker = $el("input", {
43
+ type: "color",
44
+ parent: document.body,
45
+ style: {
46
+ display: "none",
47
+ },
48
+ });
49
+ picker.onchange = () => {
50
+ if (activeNode) {
51
+ const fApplyColor = function(node){
52
+ if (picker.value) {
53
+ if (node.constructor === LiteGraph.LGraphGroup) {
54
+ node.color = picker.value;
55
+ } else {
56
+ node.color = colorShade(picker.value, 20);
57
+ node.bgcolor = picker.value;
58
+ }
59
+ }
60
+ }
61
+ const graphcanvas = LGraphCanvas.active_canvas;
62
+ if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1){
63
+ fApplyColor(activeNode);
64
+ } else {
65
+ for (let i in graphcanvas.selected_nodes) {
66
+ fApplyColor(graphcanvas.selected_nodes[i]);
67
+ }
68
+ }
69
+
70
+ activeNode.setDirtyCanvas(true, true);
71
+ }
72
+ };
73
+ }
74
+ activeNode = null;
75
+ picker.value = node.bgcolor;
76
+ activeNode = node;
77
+ picker.click();
78
+ };
79
+ },
80
+ },
81
+ [
82
+ $el("span", {
83
+ style: {
84
+ paddingLeft: "4px",
85
+ display: "block",
86
+ },
87
+ textContent: "🎨 Custom",
88
+ }),
89
+ ]
90
+ );
91
+ break;
92
+ }
93
+ }
94
+ });
95
+ return r;
96
+ };
97
+ },
98
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/faviconStatus.js ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api } from "../../../scripts/api.js";
2
+ import { app } from "../../../scripts/app.js";
3
+
4
+ // Simple script that adds the current queue size to the window title
5
+ // Adds a favicon that changes color while active
6
+
7
+ app.registerExtension({
8
+ name: "pysssss.FaviconStatus",
9
+ async setup() {
10
+ let link = document.querySelector("link[rel~='icon']");
11
+ if (!link) {
12
+ link = document.createElement("link");
13
+ link.rel = "icon";
14
+ document.head.appendChild(link);
15
+ }
16
+
17
+ const getUrl = (active, user) => new URL(`assets/favicon${active ? "-active" : ""}${user ? ".user" : ""}.ico`, import.meta.url);
18
+ const testUrl = async (active) => {
19
+ const url = getUrl(active, true);
20
+ const r = await fetch(url, {
21
+ method: "HEAD",
22
+ });
23
+ if (r.status === 200) {
24
+ return url;
25
+ }
26
+ return getUrl(active, false);
27
+ };
28
+ const activeUrl = await testUrl(true);
29
+ const idleUrl = await testUrl(false);
30
+
31
+ let executing = false;
32
+ const update = () => (link.href = executing ? activeUrl : idleUrl);
33
+
34
+ for (const e of ["execution_start", "progress"]) {
35
+ api.addEventListener(e, () => {
36
+ executing = true;
37
+ update();
38
+ });
39
+ }
40
+
41
+ api.addEventListener("executing", ({ detail }) => {
42
+ // null will be sent when it's finished
43
+ executing = !!detail;
44
+ update();
45
+ });
46
+
47
+ api.addEventListener("status", ({ detail }) => {
48
+ let title = "ComfyUI";
49
+ if (detail && detail.exec_info.queue_remaining) {
50
+ title = `(${detail.exec_info.queue_remaining}) ${title}`;
51
+ }
52
+ document.title = title;
53
+ update();
54
+ executing = false;
55
+ });
56
+ update();
57
+ },
58
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/graphArrange.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+
3
+ app.registerExtension({
4
+ name: "pysssss.GraphArrange",
5
+ setup(app) {
6
+ const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
7
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
8
+ const options = orig.apply(this, arguments);
9
+ options.push({ content: "Arrange (float left)", callback: () => graph.arrange() });
10
+ options.push({
11
+ content: "Arrange (float right)",
12
+ callback: () => {
13
+ (function () {
14
+ var margin = 50;
15
+ var layout;
16
+
17
+ const nodes = this.computeExecutionOrder(false, true);
18
+ const columns = [];
19
+
20
+ // Find node first use
21
+ for (let i = nodes.length - 1; i >= 0; i--) {
22
+ const node = nodes[i];
23
+ let max = null;
24
+ for (const out of node.outputs || []) {
25
+ if (out.links) {
26
+ for (const link of out.links) {
27
+ const outNode = app.graph.getNodeById(app.graph.links[link].target_id);
28
+ if (!outNode) continue;
29
+ var l = outNode._level - 1;
30
+ if (max === null) max = l;
31
+ else if (l < max) max = l;
32
+ }
33
+ }
34
+ }
35
+ if (max != null) node._level = max;
36
+ }
37
+
38
+ for (let i = 0; i < nodes.length; ++i) {
39
+ const node = nodes[i];
40
+ const col = node._level || 1;
41
+ if (!columns[col]) {
42
+ columns[col] = [];
43
+ }
44
+ columns[col].push(node);
45
+ }
46
+
47
+ let x = margin;
48
+
49
+ for (let i = 0; i < columns.length; ++i) {
50
+ const column = columns[i];
51
+ if (!column) {
52
+ continue;
53
+ }
54
+ column.sort((a, b) => {
55
+ var as = !(a.type === "SaveImage" || a.type === "PreviewImage");
56
+ var bs = !(b.type === "SaveImage" || b.type === "PreviewImage");
57
+ var r = as - bs;
58
+ if (r === 0) r = (a.inputs?.length || 0) - (b.inputs?.length || 0);
59
+ if (r === 0) r = (a.outputs?.length || 0) - (b.outputs?.length || 0);
60
+ return r;
61
+ });
62
+ let max_size = 100;
63
+ let y = margin + LiteGraph.NODE_TITLE_HEIGHT;
64
+ for (let j = 0; j < column.length; ++j) {
65
+ const node = column[j];
66
+ node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x;
67
+ node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y;
68
+ const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0;
69
+ if (node.size[max_size_index] > max_size) {
70
+ max_size = node.size[max_size_index];
71
+ }
72
+ const node_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 0 : 1;
73
+ y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT + j;
74
+ }
75
+
76
+ // Right align in column
77
+ for (let j = 0; j < column.length; ++j) {
78
+ const node = column[j];
79
+ node.pos[0] += max_size - node.size[0];
80
+ }
81
+ x += max_size + margin;
82
+ }
83
+
84
+ this.setDirtyCanvas(true, true);
85
+ }).apply(app.graph);
86
+ },
87
+ });
88
+ return options;
89
+ };
90
+ },
91
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/imageFeed.js ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api } from "../../../scripts/api.js";
2
+ import { app } from "../../../scripts/app.js";
3
+ import { $el } from "../../../scripts/ui.js";
4
+ import { lightbox } from "./common/lightbox.js";
5
+
6
+ $el("style", {
7
+ textContent: `
8
+ .pysssss-image-feed {
9
+ position: absolute;
10
+ background: var(--comfy-menu-bg);
11
+ color: var(--fg-color);
12
+ z-index: 99;
13
+ font-family: sans-serif;
14
+ font-size: 12px;
15
+ display: flex;
16
+ flex-direction: column;
17
+ }
18
+ div > .pysssss-image-feed {
19
+ position: static;
20
+ }
21
+ .pysssss-image-feed--top, .pysssss-image-feed--bottom {
22
+ width: 100vw;
23
+ min-height: 30px;
24
+ max-height: calc(var(--max-size, 20) * 1vh);
25
+ }
26
+ .pysssss-image-feed--top {
27
+ top: 0;
28
+ }
29
+ .pysssss-image-feed--bottom {
30
+ bottom: 0;
31
+ flex-direction: column-reverse;
32
+ padding-top: 5px;
33
+ }
34
+ .pysssss-image-feed--left, .pysssss-image-feed--right {
35
+ top: 0;
36
+ height: 100vh;
37
+ min-width: 200px;
38
+ max-width: calc(var(--max-size, 10) * 1vw);
39
+ }
40
+ .comfyui-body-left .pysssss-image-feed--left, .comfyui-body-right .pysssss-image-feed--right {
41
+ height: 100%;
42
+ }
43
+ .pysssss-image-feed--left {
44
+ left: 0;
45
+ }
46
+ .pysssss-image-feed--right {
47
+ right: 0;
48
+ }
49
+
50
+ .pysssss-image-feed--left .pysssss-image-feed-menu, .pysssss-image-feed--right .pysssss-image-feed-menu {
51
+ flex-direction: column;
52
+ }
53
+
54
+ .pysssss-image-feed-menu {
55
+ position: relative;
56
+ flex: 0 1 min-content;
57
+ display: flex;
58
+ gap: 5px;
59
+ padding: 5px;
60
+ justify-content: space-between;
61
+ }
62
+ .pysssss-image-feed-btn-group {
63
+ align-items: stretch;
64
+ display: flex;
65
+ gap: .5rem;
66
+ flex: 0 1 fit-content;
67
+ justify-content: flex-end;
68
+ }
69
+ .pysssss-image-feed-btn {
70
+ background-color:var(--comfy-input-bg);
71
+ border-radius:5px;
72
+ border:2px solid var(--border-color);
73
+ color: var(--fg-color);
74
+ cursor:pointer;
75
+ display:inline-block;
76
+ flex: 0 1 fit-content;
77
+ text-decoration:none;
78
+ }
79
+ .pysssss-image-feed-btn.sizing-btn:checked {
80
+ filter: invert();
81
+ }
82
+ .pysssss-image-feed-btn.clear-btn {
83
+ padding: 5px 20px;
84
+ }
85
+ .pysssss-image-feed-btn.hide-btn {
86
+ padding: 5px;
87
+ aspect-ratio: 1 / 1;
88
+ }
89
+ .pysssss-image-feed-btn:hover {
90
+ filter: brightness(1.2);
91
+ }
92
+ .pysssss-image-feed-btn:active {
93
+ position:relative;
94
+ top:1px;
95
+ }
96
+
97
+ .pysssss-image-feed-menu section {
98
+ border-radius: 5px;
99
+ background: rgba(0,0,0,0.6);
100
+ padding: 0 5px;
101
+ display: flex;
102
+ gap: 5px;
103
+ align-items: center;
104
+ position: relative;
105
+ }
106
+ .pysssss-image-feed-menu section span {
107
+ white-space: nowrap;
108
+ }
109
+ .pysssss-image-feed-menu section input {
110
+ flex: 1 1 100%;
111
+ background: rgba(0,0,0,0.6);
112
+ border-radius: 5px;
113
+ overflow: hidden;
114
+ z-index: 100;
115
+ }
116
+
117
+ .sizing-menu {
118
+ position: relative;
119
+ }
120
+
121
+ .size-controls-flyout {
122
+ position: absolute;
123
+ transform: scaleX(0%);
124
+ transition: 200ms ease-out;
125
+ transition-delay: 500ms;
126
+ z-index: 101;
127
+ width: 300px;
128
+ }
129
+
130
+ .sizing-menu:hover .size-controls-flyout {
131
+ transform: scale(1, 1);
132
+ transition: 200ms linear;
133
+ transition-delay: 0;
134
+ }
135
+ .pysssss-image-feed--bottom .size-controls-flyout {
136
+ transform: scale(1,0);
137
+ transform-origin: bottom;
138
+ bottom: 0;
139
+ left: 0;
140
+ }
141
+ .pysssss-image-feed--top .size-controls-flyout {
142
+ transform: scale(1,0);
143
+ transform-origin: top;
144
+ top: 0;
145
+ left: 0;
146
+ }
147
+ .pysssss-image-feed--left .size-controls-flyout {
148
+ transform: scale(0, 1);
149
+ transform-origin: left;
150
+ top: 0;
151
+ left: 0;
152
+ }
153
+ .pysssss-image-feed--right .size-controls-flyout {
154
+ transform: scale(0, 1);
155
+ transform-origin: right;
156
+ top: 0;
157
+ right: 0;
158
+ }
159
+
160
+ .pysssss-image-feed-menu > * {
161
+ min-height: 24px;
162
+ }
163
+ .pysssss-image-feed-list {
164
+ flex: 1 1 auto;
165
+ overflow-y: auto;
166
+ display: grid;
167
+ align-items: center;
168
+ justify-content: center;
169
+ gap: 4px;
170
+ grid-auto-rows: min-content;
171
+ grid-template-columns: repeat(var(--img-sz, 3), 1fr);
172
+ transition: 100ms linear;
173
+ scrollbar-gutter: stable both-edges;
174
+ padding: 5px;
175
+ background: var(--comfy-input-bg);
176
+ border-radius: 5px;
177
+ margin: 5px;
178
+ margin-top: 0px;
179
+ }
180
+ .pysssss-image-feed-list:empty {
181
+ display: none;
182
+ }
183
+ .pysssss-image-feed-list div {
184
+ height: 100%;
185
+ text-align: center;
186
+ }
187
+ .pysssss-image-feed-list::-webkit-scrollbar {
188
+ background: var(--comfy-input-bg);
189
+ border-radius: 5px;
190
+ }
191
+ .pysssss-image-feed-list::-webkit-scrollbar-thumb {
192
+ background:var(--comfy-menu-bg);
193
+ border: 5px solid transparent;
194
+ border-radius: 8px;
195
+ background-clip: content-box;
196
+ }
197
+ .pysssss-image-feed-list::-webkit-scrollbar-thumb:hover {
198
+ background: var(--border-color);
199
+ background-clip: content-box;
200
+ }
201
+ .pysssss-image-feed-list img {
202
+ object-fit: var(--img-fit, contain);
203
+ max-width: 100%;
204
+ max-height: calc(var(--max-size) * 1vh);
205
+ border-radius: 4px;
206
+ }
207
+ .pysssss-image-feed-list img:hover {
208
+ filter: brightness(1.2);
209
+ }`,
210
+ parent: document.body,
211
+ });
212
+
213
+ app.registerExtension({
214
+ name: "pysssss.ImageFeed",
215
+ async setup() {
216
+ let visible = true;
217
+ const seenImages = new Map();
218
+ const showButton = $el("button.comfy-settings-btn", {
219
+ textContent: "🖼��",
220
+ style: {
221
+ right: "16px",
222
+ cursor: "pointer",
223
+ display: "none",
224
+ },
225
+ });
226
+ let showMenuButton;
227
+ if (!app.menu?.element.style.display && app.menu?.settingsGroup) {
228
+ showMenuButton = new (await import("../../../scripts/ui/components/button.js")).ComfyButton({
229
+ icon: "image-multiple",
230
+ action: () => showButton.click(),
231
+ tooltip: "Show Image Feed 🐍",
232
+ content: "Show Image Feed 🐍",
233
+ });
234
+ showMenuButton.enabled = false;
235
+ showMenuButton.element.style.display = "none";
236
+ app.menu.settingsGroup.append(showMenuButton);
237
+ }
238
+
239
+ const getVal = (n, d) => {
240
+ const v = localStorage.getItem("pysssss.ImageFeed." + n);
241
+ if (v && !isNaN(+v)) {
242
+ return v;
243
+ }
244
+ return d;
245
+ };
246
+
247
+ const saveVal = (n, v) => {
248
+ localStorage.setItem("pysssss.ImageFeed." + n, v);
249
+ };
250
+
251
+ const imageFeed = $el("div.pysssss-image-feed");
252
+ const imageList = $el("div.pysssss-image-feed-list");
253
+
254
+ function updateMenuParent(location) {
255
+ if (showMenuButton) {
256
+ const el = document.querySelector(".comfyui-body-" + location);
257
+ if (!el) return;
258
+ el.append(imageFeed);
259
+ } else {
260
+ if (!imageFeed.parent) {
261
+ document.body.append(imageFeed);
262
+ }
263
+ }
264
+ }
265
+
266
+ const feedLocation = app.ui.settings.addSetting({
267
+ id: "pysssss.ImageFeed.Location",
268
+ name: "🐍 Image Feed Location",
269
+ defaultValue: "bottom",
270
+ type: () => {
271
+ return $el("tr", [
272
+ $el("td", [
273
+ $el("label", {
274
+ textContent: "🐍 Image Feed Location:",
275
+ }),
276
+ ]),
277
+ $el("td", [
278
+ $el(
279
+ "select",
280
+ {
281
+ style: {
282
+ fontSize: "14px",
283
+ },
284
+ oninput: (e) => {
285
+ feedLocation.value = e.target.value;
286
+ imageFeed.className = `pysssss-image-feed pysssss-image-feed--${feedLocation.value}`;
287
+ updateMenuParent(feedLocation.value);
288
+ saveVal("Location", feedLocation.value);
289
+ window.dispatchEvent(new Event("resize"));
290
+ },
291
+ },
292
+ ["left", "top", "right", "bottom", "hidden"].map((m) =>
293
+ $el("option", {
294
+ value: m,
295
+ textContent: m,
296
+ selected: feedLocation.value === m,
297
+ })
298
+ )
299
+ ),
300
+ ]),
301
+ ]);
302
+ },
303
+ onChange(value) {
304
+ if (value === "hidden") {
305
+ imageFeed.remove();
306
+ if (showMenuButton) {
307
+ requestAnimationFrame(() => {
308
+ showMenuButton.element.style.display = "none";
309
+ });
310
+ }
311
+ showButton.style.display = "none";
312
+ } else {
313
+ showMenuButton.element.style.display = "unset";
314
+ showButton.style.display = visible ? "none" : "unset";
315
+ imageFeed.className = `pysssss-image-feed pysssss-image-feed--${value}`;
316
+ updateMenuParent(value);
317
+ }
318
+ },
319
+ });
320
+
321
+ const feedDirection = app.ui.settings.addSetting({
322
+ id: "pysssss.ImageFeed.Direction",
323
+ name: "🐍 Image Feed Direction",
324
+ defaultValue: "newest first",
325
+ type: () => {
326
+ return $el("tr", [
327
+ $el("td", [
328
+ $el("label", {
329
+ textContent: "🐍 Image Feed Direction:",
330
+ }),
331
+ ]),
332
+ $el("td", [
333
+ $el(
334
+ "select",
335
+ {
336
+ style: {
337
+ fontSize: "14px",
338
+ },
339
+ oninput: (e) => {
340
+ feedDirection.value = e.target.value;
341
+ imageList.replaceChildren(...[...imageList.childNodes].reverse());
342
+ },
343
+ },
344
+ ["newest first", "oldest first"].map((m) =>
345
+ $el("option", {
346
+ value: m,
347
+ textContent: m,
348
+ selected: feedDirection.value === m,
349
+ })
350
+ )
351
+ ),
352
+ ]),
353
+ ]);
354
+ },
355
+ });
356
+
357
+ const deduplicateFeed = app.ui.settings.addSetting({
358
+ id: "pysssss.ImageFeed.Deduplication",
359
+ name: "🐍 Image Feed Deduplication",
360
+ tooltip: `Ensures unique images in the image feed but at the cost of CPU-bound performance impact \
361
+ (from hundreds of milliseconds to seconds per image, depending on byte size). For workflows that produce duplicate images, turning this setting on may yield overall client-side performance improvements \
362
+ by reducing the number of images in the feed.
363
+
364
+ Recommended: "enabled (max performance)" uness images are erroneously deduplicated.`,
365
+ defaultValue: 0,
366
+ type: "combo",
367
+ options: (value) => {
368
+ let dedupeOptions = {"disabled": 0, "enabled (slow)": 1, "enabled (performance)": 0.5, "enabled (max performance)": 0.25};
369
+ return Object.entries(dedupeOptions).map(([k, v]) => ({
370
+ value: v,
371
+ text: k,
372
+ selected: k === value,
373
+ })
374
+ )
375
+ },
376
+ });
377
+
378
+ const maxImages = app.ui.settings.addSetting({
379
+ id: "pysssss.ImageFeed.MaxImages",
380
+ name: "🐍 Image Feed Max Images",
381
+ tooltip: `Limits the number of images in the feed to a maximum, removing the oldest images as new ones are added.`,
382
+ defaultValue: 0,
383
+ type: "number",
384
+ });
385
+
386
+ const clearButton = $el("button.pysssss-image-feed-btn.clear-btn", {
387
+ textContent: "Clear",
388
+ onclick: () => {
389
+ imageList.replaceChildren();
390
+ window.dispatchEvent(new Event("resize"));
391
+ },
392
+ });
393
+
394
+ const hideButton = $el("button.pysssss-image-feed-btn.hide-btn", {
395
+ textContent: "❌",
396
+ onclick: () => {
397
+ imageFeed.style.display = "none";
398
+ showButton.style.display = feedLocation.value === "hidden" ? "none" : "unset";
399
+ if (showMenuButton) {
400
+ showMenuButton.enabled = true;
401
+ showMenuButton.element.style.display = "";
402
+ }
403
+ saveVal("Visible", 0);
404
+ visible = false;
405
+ window.dispatchEvent(new Event("resize"));
406
+ },
407
+ });
408
+
409
+ let columnInput;
410
+ function updateColumnCount(v) {
411
+ columnInput.parentElement.title = `Controls the number of columns in the feed (${v} columns).\nClick label to set custom value.`;
412
+ imageFeed.style.setProperty("--img-sz", v);
413
+ saveVal("ImageSize", v);
414
+ columnInput.max = Math.max(10, v, columnInput.max);
415
+ columnInput.value = v;
416
+ window.dispatchEvent(new Event("resize"));
417
+ }
418
+
419
+ function addImageToFeed(href) {
420
+ const method = feedDirection.value === "newest first" ? "prepend" : "append";
421
+
422
+ if (maxImages.value > 0 && imageList.children.length >= maxImages.value) {
423
+ imageList.children[method === "prepend" ? imageList.children.length - 1 : 0].remove();
424
+ }
425
+
426
+ imageList[method](
427
+ $el("div", [
428
+ $el(
429
+ "a",
430
+ {
431
+ target: "_blank",
432
+ href,
433
+ onclick: (e) => {
434
+ const imgs = [...imageList.querySelectorAll("img")].map((img) => img.getAttribute("src"));
435
+ lightbox.show(imgs, imgs.indexOf(href));
436
+ e.preventDefault();
437
+ },
438
+ },
439
+ [$el("img", { src: href })]
440
+ ),
441
+ ])
442
+ );
443
+ // If lightbox is open, update it with new image
444
+ lightbox.updateWithNewImage(href, feedDirection.value);
445
+ }
446
+
447
+ imageFeed.append(
448
+ $el("div.pysssss-image-feed-menu", [
449
+ $el("section.sizing-menu", {}, [
450
+ $el("label.size-control-handle", { textContent: "↹ Resize Feed" }),
451
+ $el("div.size-controls-flyout", {}, [
452
+ $el("section.size-control.feed-size-control", {}, [
453
+ $el("span", {
454
+ textContent: "Feed Size...",
455
+ }),
456
+ $el("input", {
457
+ type: "range",
458
+ min: 10,
459
+ max: 80,
460
+ oninput: (e) => {
461
+ e.target.parentElement.title = `Controls the maximum size of the image feed panel (${e.target.value}vh)`;
462
+ imageFeed.style.setProperty("--max-size", e.target.value);
463
+ saveVal("FeedSize", e.target.value);
464
+ window.dispatchEvent(new Event("resize"));
465
+ },
466
+ $: (el) => {
467
+ requestAnimationFrame(() => {
468
+ el.value = getVal("FeedSize", 25);
469
+ el.oninput({ target: el });
470
+ });
471
+ },
472
+ }),
473
+ ]),
474
+ $el("section.size-control.image-size-control", {}, [
475
+ $el("a", {
476
+ textContent: "Column count...",
477
+ style: {
478
+ cursor: "pointer",
479
+ textDecoration: "underline",
480
+ },
481
+ onclick: () => {
482
+ const v = +prompt("Enter custom column count", 20);
483
+ if (!isNaN(v)) {
484
+ updateColumnCount(v);
485
+ }
486
+ },
487
+ }),
488
+ $el("input", {
489
+ type: "range",
490
+ min: 1,
491
+ max: 10,
492
+ step: 1,
493
+ oninput: (e) => {
494
+ updateColumnCount(e.target.value);
495
+ },
496
+ $: (el) => {
497
+ columnInput = el;
498
+ requestAnimationFrame(() => {
499
+ updateColumnCount(getVal("ImageSize", 4));
500
+ });
501
+ },
502
+ }),
503
+ ]),
504
+ ]),
505
+ ]),
506
+ $el("div.pysssss-image-feed-btn-group", {}, [clearButton, hideButton]),
507
+ ]),
508
+ imageList
509
+ );
510
+ showButton.onclick = () => {
511
+ imageFeed.style.display = "flex";
512
+ showButton.style.display = "none";
513
+ if (showMenuButton) {
514
+ showMenuButton.enabled = false;
515
+ showMenuButton.element.style.display = "none";
516
+ }
517
+
518
+ saveVal("Visible", 1);
519
+ visible = true;
520
+ window.dispatchEvent(new Event("resize"));
521
+ };
522
+ document.querySelector(".comfy-settings-btn").after(showButton);
523
+ window.dispatchEvent(new Event("resize"));
524
+
525
+ if (!+getVal("Visible", 1)) {
526
+ hideButton.onclick();
527
+ }
528
+
529
+ api.addEventListener("executed", ({ detail }) => {
530
+ if (visible && detail?.output?.images) {
531
+ if (detail.node?.includes?.(":")) {
532
+ // Ignore group nodes
533
+ const n = app.graph.getNodeById(detail.node.split(":")[0]);
534
+ if (n?.getInnerNodes) return;
535
+ }
536
+
537
+ for (const src of detail.output.images) {
538
+ const href = `./view?filename=${encodeURIComponent(src.filename)}&type=${src.type}&
539
+ subfolder=${encodeURIComponent(src.subfolder)}&t=${+new Date()}`;
540
+
541
+ // deduplicateFeed.value is essentially the scaling factor used for image hashing
542
+ // but when deduplication is disabled, this value is "0"
543
+ if (deduplicateFeed.value > 0) {
544
+ // deduplicate by ignoring images with the same filename/type/subfolder
545
+ const fingerprint = JSON.stringify({ filename: src.filename, type: src.type, subfolder: src.subfolder });
546
+ if (seenImages.has(fingerprint)) {
547
+ // NOOP: image is a duplicate
548
+ } else {
549
+ seenImages.set(fingerprint, true);
550
+ let img = $el("img", { src: href })
551
+ img.onerror = () => {
552
+ // fall back to default behavior
553
+ addImageToFeed(href);
554
+ }
555
+ img.onload = () => {
556
+ // redraw the image onto a canvas to strip metadata (resize if performance mode)
557
+ let imgCanvas = document.createElement("canvas");
558
+ let imgScalar = deduplicateFeed.value;
559
+ imgCanvas.width = imgScalar * img.width;
560
+ imgCanvas.height = imgScalar * img.height;
561
+
562
+ let imgContext = imgCanvas.getContext("2d");
563
+ imgContext.drawImage(img, 0, 0, imgCanvas.width, imgCanvas.height);
564
+ const data = imgContext.getImageData(0, 0, imgCanvas.width, imgCanvas.height);
565
+
566
+ // calculate fast hash of the image data
567
+ let hash = 0;
568
+ for (const b of data.data) {
569
+ hash = ((hash << 5) - hash) + b;
570
+ }
571
+
572
+ // add image to feed if we've never seen the hash before
573
+ if (seenImages.has(hash)) {
574
+ // NOOP: image is a duplicate
575
+ } else {
576
+ // if we got to here, then the image is unique--so add to feed
577
+ seenImages.set(hash, true);
578
+ addImageToFeed(href);
579
+ }
580
+ }
581
+ }
582
+ } else {
583
+ addImageToFeed(href);
584
+ }
585
+ }
586
+ }
587
+ });
588
+ },
589
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/kSamplerAdvDenoise.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+ app.registerExtension({
3
+ name: "pysssss.KSamplerAdvDenoise",
4
+ async beforeRegisterNodeDef(nodeType) {
5
+ // Add menu options to conver to/from widgets
6
+ const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
7
+ nodeType.prototype.getExtraMenuOptions = function (_, options) {
8
+ const r = origGetExtraMenuOptions?.apply?.(this, arguments);
9
+
10
+ let stepsWidget = null;
11
+ let startAtWidget = null;
12
+ let endAtWidget = null;
13
+ for (const w of this.widgets || []) {
14
+ if (w.name === "steps") {
15
+ stepsWidget = w;
16
+ } else if (w.name === "start_at_step") {
17
+ startAtWidget = w;
18
+ } else if (w.name === "end_at_step") {
19
+ endAtWidget = w;
20
+ }
21
+ }
22
+
23
+ if (stepsWidget && startAtWidget && endAtWidget) {
24
+ options.push(
25
+ {
26
+ content: "Set Denoise",
27
+ callback: () => {
28
+ const steps = +prompt("How many steps do you want?", 15);
29
+ if (isNaN(steps)) {
30
+ return;
31
+ }
32
+ const denoise = +prompt("How much denoise? (0-1)", 0.5);
33
+ if (isNaN(denoise)) {
34
+ return;
35
+ }
36
+
37
+ stepsWidget.value = Math.floor(steps / Math.max(0, Math.min(1, denoise)));
38
+ stepsWidget.callback?.(stepsWidget.value);
39
+
40
+ startAtWidget.value = stepsWidget.value - steps;
41
+ startAtWidget.callback?.(startAtWidget.value);
42
+
43
+ endAtWidget.value = stepsWidget.value;
44
+ endAtWidget.callback?.(endAtWidget.value);
45
+ },
46
+ },
47
+ null
48
+ );
49
+ }
50
+
51
+ return r;
52
+ };
53
+ },
54
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/linkRenderMode.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+ import { $el } from "../../../scripts/ui.js";
3
+
4
+ const id = "pysssss.LinkRenderMode";
5
+ const ext = {
6
+ name: id,
7
+ async setup(app) {
8
+ if (app.extensions.find((ext) => ext.name === "Comfy.LinkRenderMode")) {
9
+ console.log("%c[🐍 pysssss]", "color: limegreen", "Skipping LinkRenderMode as core extension found");
10
+ return;
11
+ }
12
+ const setting = app.ui.settings.addSetting({
13
+ id,
14
+ name: "🐍 Link Render Mode",
15
+ defaultValue: 2,
16
+ type: () => {
17
+ return $el("tr", [
18
+ $el("td", [
19
+ $el("label", {
20
+ for: id.replaceAll(".", "-"),
21
+ textContent: "🐍 Link Render Mode:",
22
+ }),
23
+ ]),
24
+ $el("td", [
25
+ $el(
26
+ "select",
27
+ {
28
+ textContent: "Manage",
29
+ style: {
30
+ fontSize: "14px",
31
+ },
32
+ oninput: (e) => {
33
+ setting.value = e.target.value;
34
+ app.canvas.links_render_mode = +e.target.value;
35
+ app.graph.setDirtyCanvas(true, true);
36
+ },
37
+ },
38
+ LiteGraph.LINK_RENDER_MODES.map((m, i) =>
39
+ $el("option", {
40
+ value: i,
41
+ textContent: m,
42
+ selected: i == app.canvas.links_render_mode,
43
+ })
44
+ )
45
+ ),
46
+ ]),
47
+ ]);
48
+ },
49
+ onChange(value) {
50
+ app.canvas.links_render_mode = +value;
51
+ app.graph.setDirtyCanvas(true);
52
+ },
53
+ });
54
+ },
55
+ };
56
+
57
+ app.registerExtension(ext);
custom_nodes/ComfyUI-Custom-Scripts/web/js/locking.js ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+
3
+ // Adds lock/unlock menu item for nodes + groups to prevent moving / resizing them
4
+
5
+ const LOCKED = Symbol();
6
+
7
+ function lockArray(arr, isLocked) {
8
+ if (!Array.isArray(arr)) return; // Prevent crash on es6
9
+ const v = [];
10
+
11
+ for (let i = 0; i < 2; i++) {
12
+ v[i] = arr[i];
13
+
14
+ Object.defineProperty(arr, i, {
15
+ get() {
16
+ return v[i];
17
+ },
18
+ set(value) {
19
+ if (!isLocked()) {
20
+ v[i] = value;
21
+ }
22
+ },
23
+ });
24
+ }
25
+ }
26
+
27
+ app.registerExtension({
28
+ name: "pysssss.Locking",
29
+ init() {
30
+ function lockGroup(node) {
31
+ node[LOCKED] = true;
32
+ }
33
+
34
+ // Add the locked flag to serialization
35
+ const serialize = LGraphGroup.prototype.serialize;
36
+ LGraphGroup.prototype.serialize = function () {
37
+ const o = serialize.apply(this, arguments);
38
+ o.locked = !!this[LOCKED];
39
+ return o;
40
+ };
41
+
42
+ // On initial configure lock group if required
43
+ const configure = LGraphGroup.prototype.configure;
44
+ LGraphGroup.prototype.configure = function (o) {
45
+ configure.apply(this, arguments);
46
+ if (o.locked) {
47
+ lockGroup(this);
48
+ }
49
+ };
50
+
51
+ // Allow click through locked groups
52
+ const getGroupOnPos = LGraph.prototype.getGroupOnPos;
53
+ LGraph.prototype.getGroupOnPos = function () {
54
+ const r = getGroupOnPos.apply(this, arguments);
55
+ if (r && r[LOCKED] && !new Error().stack.includes("processContextMenu")) return null;
56
+ return r;
57
+ };
58
+
59
+ // Add menu options for lock/unlock
60
+ const getGroupMenuOptions = LGraphCanvas.prototype.getGroupMenuOptions;
61
+ LGraphCanvas.prototype.getGroupMenuOptions = function (node) {
62
+ const opts = getGroupMenuOptions.apply(this, arguments);
63
+
64
+ opts.unshift(
65
+ node[LOCKED]
66
+ ? {
67
+ content: "Unlock",
68
+ callback: () => {
69
+ delete node[LOCKED];
70
+ },
71
+ }
72
+ : {
73
+ content: "Lock",
74
+ callback: () => lockGroup(node),
75
+ },
76
+ null
77
+ );
78
+
79
+ return opts;
80
+ };
81
+ },
82
+ setup() {
83
+ const drawNodeShape = LGraphCanvas.prototype.drawNodeShape;
84
+ LGraphCanvas.prototype.drawNodeShape = function (node, ctx, size, fgcolor, bgcolor, selected, mouse_over) {
85
+ const res = drawNodeShape.apply(this, arguments);
86
+
87
+ if (node[LOCKED]) {
88
+ ctx.fillText("🔒", node.getBounding()[2] - 20, -10);
89
+ }
90
+
91
+ return res;
92
+ };
93
+ },
94
+ async beforeRegisterNodeDef(nodeType) {
95
+ const nodesArray = (nodes) => {
96
+ if (nodes) {
97
+ if (nodes instanceof Array) {
98
+ return nodes;
99
+ }
100
+ return [nodes];
101
+ }
102
+ return Object.values(app.canvas.selected_nodes);
103
+ };
104
+ function unlockNode(nodes) {
105
+ nodes = nodesArray(nodes);
106
+ for (const node of nodes) {
107
+ delete node[LOCKED];
108
+ }
109
+ app.graph.setDirtyCanvas(true, false);
110
+ }
111
+ function lockNode(nodes) {
112
+ nodes = nodesArray(nodes);
113
+ for (const node of nodes) {
114
+ if (node[LOCKED]) continue;
115
+
116
+ node[LOCKED] = true;
117
+ // Same hack as above
118
+ lockArray(node.pos, () => !!node[LOCKED]);
119
+
120
+ // Size is set by both replacing the value and setting individual values
121
+ // So define a new property that can prevent reassignment
122
+ const sz = [node.size[0], node.size[1]];
123
+ Object.defineProperty(node, "size", {
124
+ get() {
125
+ return sz;
126
+ },
127
+ set(value) {
128
+ if (!node[LOCKED]) {
129
+ sz[0] = value[0];
130
+ sz[1] = value[1];
131
+ }
132
+ },
133
+ });
134
+ // And then lock each element if required
135
+ lockArray(sz, () => !!node[LOCKED]);
136
+ }
137
+
138
+ app.graph.setDirtyCanvas(true, false);
139
+ }
140
+
141
+ // Add menu options for lock/unlock
142
+ const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
143
+ nodeType.prototype.getExtraMenuOptions = function (_, options) {
144
+ const r = getExtraMenuOptions ? getExtraMenuOptions.apply(this, arguments) : undefined;
145
+
146
+ options.splice(
147
+ options.findIndex((o) => o?.content === "Properties") + 1,
148
+ 0,
149
+ null,
150
+ this[LOCKED]
151
+ ? {
152
+ content: "Unlock",
153
+ callback: () => {
154
+ unlockNode();
155
+ },
156
+ }
157
+ : {
158
+ content: "Lock",
159
+ callback: () => lockNode(),
160
+ }
161
+ );
162
+
163
+ return r;
164
+ };
165
+
166
+ // Add the locked flag to serialization
167
+ const onSerialize = nodeType.prototype.onSerialize;
168
+ nodeType.prototype.onSerialize = function (o) {
169
+ if (onSerialize) {
170
+ onSerialize.apply(this, arguments);
171
+ }
172
+ o.locked = this[LOCKED];
173
+ };
174
+
175
+ // On initial configure lock node if required
176
+ const onConfigure = nodeType.prototype.onConfigure;
177
+ nodeType.prototype.onConfigure = function (o) {
178
+ if (onConfigure) {
179
+ onConfigure.apply(this, arguments);
180
+ }
181
+ if (o.locked) {
182
+ lockNode(this);
183
+ }
184
+ };
185
+ },
186
+ });
custom_nodes/ComfyUI-Custom-Scripts/web/js/mathExpression.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+ import { ComfyWidgets } from "../../../scripts/widgets.js";
3
+
4
+ app.registerExtension({
5
+ name: "pysssss.MathExpression",
6
+ init() {
7
+ const STRING = ComfyWidgets.STRING;
8
+ ComfyWidgets.STRING = function (node, inputName, inputData) {
9
+ const r = STRING.apply(this, arguments);
10
+ r.widget.dynamicPrompts = inputData?.[1].dynamicPrompts;
11
+ return r;
12
+ };
13
+ },
14
+ beforeRegisterNodeDef(nodeType) {
15
+ if (nodeType.comfyClass === "MathExpression|pysssss") {
16
+ const onDrawForeground = nodeType.prototype.onDrawForeground;
17
+
18
+ nodeType.prototype.onNodeCreated = function() {
19
+ // These are typed as any to bypass backend validation
20
+ // update frontend to restrict types
21
+ for(const input of this.inputs) {
22
+ input.type = "INT,FLOAT,IMAGE,LATENT";
23
+ }
24
+ }
25
+
26
+ nodeType.prototype.onDrawForeground = function (ctx) {
27
+ const r = onDrawForeground?.apply?.(this, arguments);
28
+
29
+ const v = app.nodeOutputs?.[this.id + ""];
30
+ if (!this.flags.collapsed && v) {
31
+ const text = v.value[0] + "";
32
+ ctx.save();
33
+ ctx.font = "bold 12px sans-serif";
34
+ ctx.fillStyle = "dodgerblue";
35
+ const sz = ctx.measureText(text);
36
+ ctx.fillText(text, this.size[0] - sz.width - 5, LiteGraph.NODE_SLOT_HEIGHT * 3);
37
+ ctx.restore();
38
+ }
39
+
40
+ return r;
41
+ };
42
+ }
43
+ },
44
+ });