├── .github └── workflows │ └── publish_action.yml ├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── arial.ttf ├── images ├── icon.jpg ├── tinyterra_pipeSDXL.png ├── tinyterra_prefixParsing.png ├── tinyterra_trueHRFix.png └── tinyterra_xyPlot.png ├── js ├── ttN.css ├── ttN.js ├── ttNdropdown.js ├── ttNdynamicWidgets.js ├── ttNembedAC.js ├── ttNfullscreen.js ├── ttNinterface.js ├── ttNwidgets.js ├── ttNxyPlot.js ├── ttNxyPlotAdv.js └── utils.js ├── pyproject.toml ├── ttNdev.py └── ttNpy ├── adv_encode.py ├── tinyterraNodes.py ├── ttNexecutor.py ├── ttNlegacyNodes.py ├── ttNserver.py └── utils.py /.github/workflows/publish_action.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "pyproject.toml" 9 | 10 | permissions: 11 | issues: write 12 | 13 | jobs: 14 | publish-node: 15 | name: Publish Custom Node to registry 16 | runs-on: ubuntu-latest 17 | if: ${{ github.repository_owner == 'TinyTerra' }} 18 | steps: 19 | - name: Check out code 20 | uses: actions/checkout@v4 21 | - name: Publish Custom Node 22 | uses: Comfy-Org/publish-node-action@v1 23 | with: 24 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.ini 2 | nsp_pantry.json 3 | __pycache__ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tinyterraNodes 2 | 3 | *A selection of custom nodes for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).* 4 | 5 | **Enjoy my nodes and would like to [help keep me awake](https://buymeacoffee.com/tinyterra)?** 6 | 7 | ![tinyterra_pipeSDXL](images/tinyterra_pipeSDXL.png) 8 | ![tinyterra_trueHRFix](images/tinyterra_trueHRFix.png) 9 | ![tinyterra_trueHRFix](images/tinyterra_xyPlot.png) 10 | 11 | ## Installation 12 | Navigate to the **_ComfyUI/custom_nodes_** directory with cmd, and run: 13 | 14 | `git clone https://github.com/TinyTerra/ComfyUI_tinyterraNodes.git` 15 | 16 | ### Special Features 17 | **Fullscreen Image Viewer** 18 | 19 | *Enabled by default* 20 | 21 | +
Adds 'Fullscreen 🌏' to the node right-click context menu Opens a Fullscreen image viewer - containing all images generated by the selected node during the current comfy session. 22 | +
Adds 'Set Default Fullscreen Node 🌏' to the node right-click context menu Sets the currently selected node as the default Fullscreen node 23 | +
Adds 'Clear Default Fullscreen Node 🌏' to the node right-click context menu Clears the assigned default Fullscreen node 24 | 25 | 26 | + Slideshow Mode 27 | + Toggled On - Automatically jumps to New images as they are generated (Black Background) - the UI will auto hide after a set time. 28 | + Toggled Off - Holds to the current user selected image (Light Background) 29 | + Fullscreen Overlay 30 | + Toggles display of a navigable preview of all of the selected nodes images 31 | + Toggles display of the default comfy menu 32 | 33 | + *Shortcuts* 34 | + 'shift + F11' => _Open ttN-Fullscreen using selected node OR default fullscreen node_ 35 | + 'shift + down arrow' (Node Selected) => _Set selected node as default fullscreen node_ 36 | + 'shift + down arrow' (No Node Selected) => _removes default fullscreen node to None_ 37 | 38 | + *Shortcuts in Fullscreen* 39 | + 'shift + F11' => _Close ttN-Fullscreen_ 40 | + 'up arrow' => _Toggle Fullscreen Overlay_ 41 | + 'down arrow' => _Toggle Slideshow Mode_ 42 | + 'left arrow' => _Select Image to the left_ 43 | + 'shift + left arrow' => _Select Image 5 to the left_ 44 | + 'ctrl + left arrow' => _Select the first Image_ 45 | + 'right arrow' => _Select Image to the right_ 46 | + 'shift + right arrow' => _Select Image 5 to the right_ 47 | + 'ctrl + right arrow' => _Select last Image_ 48 | + 'mouse scroll' => _Zoom the current image in and out_ 49 | + 'ctrl + mouse scroll' => _Select image to Left/Right_ 50 | + 'left click + drag' => _Update the current image's position_ 51 | + 'double click' => _Reset position of current image_ 52 | + 'esc' => _Close Fullscreen Mode_ 53 | + Show UI with mouse hover in Slideshow mode 54 | 55 | **Advanced XY(Z)Plot** 56 | + pipeKSampler/SDXL input to generate xyz plots using any previous input nodes. 57 | + _(Any values not set by xyPlot will be taken from the corresponding nodes)_ 58 | 59 | + Advanced xyPlot can take multiple variables for each axis somewhat programmatically. 60 | 61 | + Any image input - Use the 'advPlot images' node to create an xyplot from any image input. 62 | 63 | Syntax: 64 | ``` 65 | 66 | [node_ID:widget_Name='value'] 67 | 68 | 69 | [node_ID:widget_Name='value2'] 70 | [node_ID:widget2_Name='value'] 71 | [node_ID2:widget_Name='value'] 72 | ``` 73 | For Example: 74 | ``` 75 | <1:v_label> 76 | [2:ckpt_name='model.safetensors'] 77 | 78 | <2:custom label> 79 | [2:ckpt_name='checkpoint.xyz'] 80 | [2:vae_name='someVae.xyz'] 81 | [4:text='Summer sunset'] 82 | ``` 83 | + labels: 84 | + Any custom string for a custom axis label 85 | + v_label - for a concatenation of the values being set. In the example above if both were set to v_label: 86 | + model.safetensors 87 | + checkpoint.xyz, someVae.xyz, Summer sunset 88 | + tv_label - for the option title and value concatenated. In the example above if both were set to tv_label: 89 | + ckpt_name: model.safetensors 90 | + ckpt_name: checkpoint.xyz, vae_name: someVae.xyz, text: Summer sunset 91 | + itv_label - for the node ID, option title and value concatenated. In the example above if both were set to itv_label: 92 | + [2] ckpt_name: model.safetensors 93 | + [2] ckpt_name: checkpoint.xyz, [2] vae_name: someVae.xyz, [4] text: Summer sunset 94 | + Node ID's: 95 | + Suggested to use 'Badge: ID + nickname' in [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) settings to be able to view node IDs. 96 | + Autocomplete: 97 | + ttN Autocomplete will activate when the advanced xyPlot node is connected to a sampler, and will show all the nodes and options available, as well as an 'add axis' option to auto add the code for a new axis number and label. 98 | + Search and Replace: 99 | + if you include %search;replace% as the value it will use the current nodes value and do a search and replace using these values. 100 | + you can include more than one to replace different strings 101 | + Append to original value 102 | + if you include .append to the widget name it will append the xyPlot value to the original instead of overwriting it. 103 | + For example: [1:loras.append='\'] 104 | + Z-Axis support for multi plotting 105 | + Creates extra xyPlots with the z-axis value changes as a base 106 | + Node based plotting to avoid requiring manually writing syntax 107 | + advPlot range for easily created int/float ranges 108 | + advPlot string for delimited string 'ranges' 109 | 110 | **Auto Complete** 111 | 112 | *Enabled by default* 113 | + displays a popup to autocomplete embedding filenames in text widgets - to use, start typing **embedding** and select an option from the list. 114 | + displays a popup to autocomplete noodlesoup categories - to use, start typing **__** and select an option from the list. 115 | + displays a popup in ttN 'loras' input to autocomplete loras from a list. 116 | + Option to disable ([ttNodes] enable_embed_autocomplete = True | False) 117 | 118 | **Dynamic Widgets** 119 | 120 | *Enabled by default* 121 | 122 | + Automatically hides and shows widgets depending on their relevancy 123 | + Option to disable ([ttNodes] enable_dynamic_widgets = True | False) 124 | 125 | **ttNinterface** 126 | 127 | *Enabled by default* 128 | 129 | +
Adds 'Node Dimensions 🌏' to the node right-click context menu Allows setting specific node Width and Height values as long as they are above the minimum size for the given node. 130 | +
Adds 'Default BG Color 🌏' to the node right-click context menu Allows setting specific default background color for every node added. 131 | 132 | +
Adds support for 'ctrl + arrow key' Node movement This aligns the node(s) to the set ComfyUI grid spacing size and move the node in the direction of the arrow key by the grid spacing value. Holding shift in addition will move the node by the grid spacing size * 10. 133 | +
Adds 'Reload Node 🌏' to the node right-click context menu Creates a new instance of the node with the same position, size, color and title . It attempts to retain set widget values which is useful for replacing nodes when a node/widget update occurs
134 | +
Adds 'Slot Type Color 🌏' to the Link right-click context menu Opens a color picker dialog menu to update the color of the selected link type.
135 | +
Adds 'Link Border 🌏' to the Link right-click context menu Toggles link line border.
136 | +
Adds 'Link Shadow 🌏' to the Link right-click context menu Toggles link line shadow.
137 | +
Adds 'Link Style 🌏' to the Link right-click context menu Sets the default link line type.
138 | 139 | 140 | **Save image prefix parsing** 141 | 142 | + Add date/time info to filenames or output folder by using: %date:yyyy-MM-dd-hh-mm-ss% 143 | + Parse any upstream setting into filenames or output folder by using %[widget_name]% (for the current node)
144 | or %input_name>input_name>widget_name% (for inputting nodes)
145 |
Example: 146 | 147 | 148 | ![tinyterra_prefixParsing](images/tinyterra_prefixParsing.png) 149 |
150 | 151 | **Node Versioning** 152 | 153 | + All tinyterraNodes now have a version property so that if any future changes are made to widgets that would break workflows the nodes will be highlighted on load 154 | + Will only work with workflows created/saved after the v1.0.0 release 155 | 156 | **AutoUpdate** 157 | 158 | *Disabled by default* 159 | 160 | + Option to auto-update the node pack ([ttNodes] auto_update = False | True) 161 | 162 |
163 |
164 | $\Large\color{white}{Nodes}$ 165 | 166 | ## ttN/base 167 |
168 | tinyLoader 169 |
170 | 171 |
172 | tinyConditioning 173 |
174 | 175 |
176 | tinyKSampler 177 |
178 | 179 | ## ttN/pipe 180 | 181 |
182 | pipeLoader v2 183 | 184 | (Includes [ADV_CLIP_emb](https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb)) 185 | 186 | 187 | 188 |
189 | 190 |
191 | pipeKSampler v2 192 | 193 | 194 | Embedded with Advanced CLIP Text Encode with an additional pipe output 195 | 196 | 197 | 198 | 199 | Old node layout: 200 | 201 | 202 | 203 | With pipeLoader and pipeKSampler: 204 | 205 | 206 |
207 | 208 |
209 | pipeKSamplerAdvanced v2 210 | 211 | Embedded with Advanced CLIP Text Encode with an additional pipe output 212 | 213 | 214 |
215 | 216 |
217 | pipeLoaderSDXL v2 218 | 219 | SDXL Loader and Advanced CLIP Text Encode with an additional pipe output 220 | 221 | 222 | 223 |
224 | 225 |
226 | pipeKSamplerSDXL v2 227 | 228 | SDXL Sampler (base and refiner in one) and Advanced CLIP Text Encode with an additional pipe output 229 | 230 | 231 | 232 | Old node layout: 233 | 234 | 235 | 236 | With pipeLoaderSDXL and pipeKSamplerSDXL: 237 | 238 | 239 |
240 | 241 | 242 | 243 |
244 | pipeEDIT 245 | 246 | Update/Overwrite any of the 8 original inputs in a Pipe line with new information. 247 | + _**Inputs -** pipe, model, conditioning, conditioning, samples, vae, clip, image, seed_ 248 | + _**Outputs -** pipe_ 249 |
250 | 251 |
252 | pipe > basic_pipe 253 | 254 | Convert ttN pipe line to basic pipe (to be compatible with [ImpactPack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)), WITH original pipe throughput 255 | + _**Inputs -** pipe[model, conditioning, conditioning, samples, vae, clip, image, seed]_ 256 | + _**Outputs -** basic_pipe[model, clip, vae, conditioning, conditioning], pipe_ 257 |
258 | 259 |
260 | pipe > Detailer Pipe 261 | 262 | Convert ttN pipe line to detailer pipe (to be compatible with [ImpactPack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)), WITH original pipe throughput 263 | + _**Inputs -** pipe[model, conditioning, conditioning, samples, vae, clip, image, seed], bbox_detector, sam_model_opt_ 264 | + _**Outputs -** detailer_pipe[model, vae, conditioning, conditioning, bbox_detector, sam_model_opt], pipe_ 265 |
266 | 267 | ## ttN/xyPlot 268 |
269 | adv xyPlot 270 | 271 | pipeKSampler input to generate xy plots using sampler and loader values. (Any values not set by xyPlot will be taken from the corresponding nodes) 272 | 273 | ![adv_xyPlot](https://github.com/TinyTerra/ComfyUI_tinyterraNodes/assets/115619949/4443f88e-5e95-413b-9eb1-caf643b19ba1) 274 |
275 | 276 |
277 | advPlot images 278 | 279 | Node to generate xyz plots from any image inputs. 280 |
281 | 282 |
283 | advPlot range 284 | 285 | adv_xyPlot input to generate plot syntax across a range of values. 286 |
287 | 288 |
289 | advPlot string 290 | 291 | adv_xyPlot input to generate plot syntax for strings via a delimiter. 292 |
293 | 294 |
295 | advPlot combo 296 | 297 | adv_xyPlot input to generate plot syntax for combos with various modes. 298 |
299 | 300 | ## ttN/image 301 | 302 |
303 | imageOutput 304 | 305 | Preview or Save an image with one node, with image throughput. 306 | + _**Inputs -** image, image output[Hide, Preview, Save, Hide/Save], output path, save prefix, number padding[None, 2-9], file type[PNG, JPG, JPEG, BMP, TIFF, TIF] overwrite existing[True, False], embed workflow[True, False]_ 307 | + _**Outputs -** image_ 308 | 309 |
310 | 311 |
312 | imageRemBG 313 | 314 | (Using [RemBG](https://github.com/danielgatis/rembg)) 315 | 316 | Background Removal node with optional image preview & save. 317 | + _**Inputs -** image, image output[Disabled, Preview, Save], save prefix_ 318 | + _**Outputs -** image, mask_ 319 | 320 | Example of a photobashing workflow using pipeNodes, imageRemBG, imageOutput and nodes from [ADV_CLIP_emb](https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb) and [ImpactPack](https://github.com/ltdrdata/ComfyUI-Impact-Pack/tree/Main): 321 | ![photobash](workflows/tinyterra_imagebash.png) 322 | 323 |
324 | 325 |
326 | hiresFix 327 | 328 | Upscale image by model, optional rescale of result image. 329 | + _**Inputs -** image, vae, upscale_model, rescale_after_model[true, false], rescale[by_percentage, to Width/Height], rescale method[nearest-exact, bilinear, area], factor, width, height, crop, image_output[Hide, Preview, Save], save prefix, output_latent[true, false]_ 330 | + _**Outputs -** image, latent_ 331 |
332 | 333 | ## ttN/text 334 |
335 | text 336 | 337 | Basic TextBox Loader. 338 | + _**Outputs -** text (STRING)_ 339 |
340 | 341 |
342 | textDebug 343 | 344 | Text input, to display text inside the node, with optional print to console. 345 | + _**inputs -** text, print_to_console_ 346 | + _**Outputs -** text (STRING)_ 347 |
348 | 349 |
350 | textConcat 351 | 352 | 3 TextBOX inputs with a single concatenated output. 353 | + _**inputs -** text1, text2, text3 (STRING's), delimiter_ 354 | + _**Outputs -** text (STRING)_ 355 |
356 | 357 |
358 | 7x TXT Loader Concat 359 | 360 | 7 TextBOX inputs concatenated with spaces into a single output, AND separate text outputs. 361 | + _**inputs -** text1, text2, text3, text4, text5, text6, text7 (STRING's), delimiter_ 362 | + _**Outputs -** text1, text2, text3, text4, text5, text6, text7, concat (STRING's)_ 363 |
364 | 365 |
366 | 3x TXT Loader MultiConcat 367 | 368 | 3 TextBOX inputs with separate text outputs AND multiple concatenation variations (concatenated with spaces). 369 | + _**inputs -** text1, text2, text3 (STRING's), delimiter_ 370 | + _**Outputs -** text1, text2, text3, 1 & 2, 1 & 3, 2 & 3, concat (STRING's)_ 371 |
372 | 373 | ## ttN/util 374 |
375 | seed 376 | 377 | Basic Seed Loader. 378 | + _**Outputs -** seed (INT)_ 379 |
380 | 381 |
382 | float 383 | 384 | float loader and converter 385 | + _**inputs -** float (FLOAT)_ 386 | + _**Outputs -** float, int, text (FLOAT, INT, STRING)_ 387 |
388 | 389 |
390 | int 391 | 392 | int loader and converter 393 | + _**inputs -** int (INT)_ 394 | + _**Outputs -** int, float, text (INT, FLOAT, STRING)_ 395 |
396 | 397 |
398 | 399 | ## ttN/legacy 400 | 401 |
402 | pipeLoader v1 403 | 404 | (Modified from [Efficiency Nodes](https://github.com/LucianoCirino/efficiency-nodes-comfyui) and [ADV_CLIP_emb](https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb)) 405 | 406 | Combination of Efficiency Loader and Advanced CLIP Text Encode with an additional pipe output 407 | + _**Inputs -** model, vae, clip skip, (lora1, modelstrength clipstrength), (Lora2, modelstrength clipstrength), (Lora3, modelstrength clipstrength), (positive prompt, token normalization, weight interpretation), (negative prompt, token normalization, weight interpretation), (latent width, height), batch size, seed_ 408 | + _**Outputs -** pipe, model, conditioning, conditioning, samples, vae, clip, seed_ 409 |
410 | 411 |
412 | pipeKSampler v1 413 | 414 | (Modified from [Efficiency Nodes](https://github.com/LucianoCirino/efficiency-nodes-comfyui) and [QOLS_Omar92](https://github.com/omar92/ComfyUI-QualityOfLifeSuit_Omar92)) 415 | 416 | Combination of Efficiency Loader and Advanced CLIP Text Encode with an additional pipe output 417 | + _**Inputs -** pipe, (optional pipe overrides), xyplot, (Lora, model strength, clip strength), (upscale method, factor, crop), sampler state, steps, cfg, sampler name, scheduler, denoise, (image output [None, Preview, Save]), Save_Prefix, seed_ 418 | + _**Outputs -** pipe, model, conditioning, conditioning, samples, vae, clip, image, seed_ 419 | 420 | Old node layout: 421 | 422 | 423 | 424 | With pipeLoader and pipeKSampler: 425 | 426 | 427 |
428 | 429 |
430 | pipeKSamplerAdvanced v1 431 | 432 | Combination of Efficiency Loader and Advanced CLIP Text Encode with an additional pipe output 433 | + _**Inputs -** pipe, (optional pipe overrides), xyplot, (Lora, model strength, clip strength), (upscale method, factor, crop), sampler state, steps, cfg, sampler name, scheduler, starts_at_step, return_with_leftover_noise, (image output [None, Preview, Save]), Save_Prefix_ 434 | + _**Outputs -** pipe, model, conditioning, conditioning, samples, vae, clip, image, seed_ 435 | 436 |
437 | 438 |
439 | pipeLoaderSDXL v1 440 | 441 | SDXL Loader and Advanced CLIP Text Encode with an additional pipe output 442 | + _**Inputs -** model, vae, clip skip, (lora1, modelstrength clipstrength), (Lora2, modelstrength clipstrength), model, vae, clip skip, (lora1, modelstrength clipstrength), (Lora2, modelstrength clipstrength), (positive prompt, token normalization, weight interpretation), (negative prompt, token normalization, weight interpretation), (latent width, height), batch size, seed_ 443 | + _**Outputs -** sdxlpipe, model, conditioning, conditioning, vae, model, conditioning, conditioning, vae, samples, clip, seed_ 444 |
445 | 446 |
447 | pipeKSamplerSDXL v1 448 | 449 | SDXL Sampler (base and refiner in one) and Advanced CLIP Text Encode with an additional pipe output 450 | + _**Inputs -** sdxlpipe, (optional pipe overrides), (upscale method, factor, crop), sampler state, base_steps, refiner_steps cfg, sampler name, scheduler, (image output [None, Preview, Save]), Save_Prefix, seed_ 451 | + _**Outputs -** pipe, model, conditioning, conditioning, vae, model, conditioning, conditioning, vae, samples, clip, image, seed_ 452 | 453 | Old node layout: 454 | 455 | 456 | 457 | With pipeLoaderSDXL and pipeKSamplerSDXL: 458 | 459 | 460 |
461 | 462 |
463 | pipeIN 464 | 465 | Encode up to 8 frequently used inputs into a single Pipe line. 466 | + _**Inputs -** model, conditioning, conditioning, samples, vae, clip, image, seed_ 467 | + _**Outputs -** pipe_ 468 |
469 | 470 |
471 | pipeOUT 472 | 473 | Decode single Pipe line into the 8 original outputs, AND a Pipe throughput. 474 | + _**Inputs -** pipe_ 475 | + _**Outputs -** model, conditioning, conditioning, samples, vae, clip, image, seed, pipe_ 476 |
477 | 478 |
479 | pipe > xyPlot 480 | 481 | pipeKSampler input to generate xy plots using sampler and loader values. (Any values not set by xyPlot will be taken from the corresponding pipeKSampler or pipeLoader) 482 | + _**Inputs -** grid_spacing, latent_id, flip_xy, x_axis, x_values, y_axis, y_values_ 483 | + _**Outputs -** xyPlot_ 484 |
-------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .ttNpy.tinyterraNodes import TTN_VERSIONS 2 | from .ttNpy import ttNserver # Do Not Remove 3 | import configparser 4 | import folder_paths 5 | import subprocess 6 | import shutil 7 | import os 8 | 9 | # ------- CONFIG -------- # 10 | cwd_path = os.path.dirname(os.path.realpath(__file__)) 11 | js_path = os.path.join(cwd_path, "js") 12 | comfy_path = folder_paths.base_path 13 | 14 | config_path = os.path.join(cwd_path, "config.ini") 15 | 16 | optionValues = { 17 | "auto_update": ('true', 'false'), 18 | "enable_embed_autocomplete": ('true', 'false'), 19 | "enable_interface": ('true', 'false'), 20 | "enable_fullscreen": ('true', 'false'), 21 | "enable_dynamic_widgets": ('true', 'false'), 22 | "enable_dev_nodes": ('true', 'false'), 23 | } 24 | 25 | def get_config(): 26 | """Return a configparser.ConfigParser object.""" 27 | config = configparser.ConfigParser() 28 | config.read(config_path) 29 | return config 30 | 31 | def update_config(): 32 | #section > option > value 33 | for node, version in TTN_VERSIONS.items(): 34 | config_write("Versions", node, version) 35 | 36 | for option, value in optionValues.items(): 37 | config_write("Option Values", option, value) 38 | 39 | section_data = { 40 | "ttNodes": { 41 | "auto_update": False, 42 | "enable_interface": True, 43 | "enable_fullscreen": True, 44 | "enable_embed_autocomplete": True, 45 | "enable_dynamic_widgets": True, 46 | "enable_dev_nodes": False, 47 | } 48 | } 49 | 50 | for section, data in section_data.items(): 51 | for option, value in data.items(): 52 | if config_read(section, option) is None: 53 | config_write(section, option, value) 54 | 55 | # Load the configuration data into a dictionary. 56 | config_data = config_load() 57 | 58 | # Iterate through the configuration data. 59 | for section, options in config_data.items(): 60 | if section == "Versions": 61 | continue 62 | for option in options: 63 | # If the option is not in `optionValues` or in `section_data`, remove it. 64 | if (option not in optionValues and 65 | (section not in section_data or option not in section_data[section])): 66 | config_remove(section, option) 67 | 68 | def config_load(): 69 | """Load the entire configuration into a dictionary.""" 70 | config = get_config() 71 | return {section: dict(config.items(section)) for section in config.sections()} 72 | 73 | def config_read(section, option): 74 | """Read a configuration option.""" 75 | config = get_config() 76 | return config.get(section, option, fallback=None) 77 | 78 | def config_write(section, option, value): 79 | """Write a configuration option.""" 80 | config = get_config() 81 | if not config.has_section(section): 82 | config.add_section(section) 83 | config.set(section, str(option), str(value)) 84 | 85 | with open(config_path, 'w') as f: 86 | config.write(f) 87 | 88 | def config_remove(section, option): 89 | """Remove an option from a section.""" 90 | config = get_config() 91 | if config.has_section(section): 92 | config.remove_option(section, option) 93 | with open(config_path, 'w') as f: 94 | config.write(f) 95 | 96 | def config_value_validator(section, option, default): 97 | value = str(config_read(section, option)).lower() 98 | if value not in optionValues[option]: 99 | print(f'\033[92m[{section} Config]\033[91m {option} - \'{value}\' not in {optionValues[option]}, reverting to default.\033[0m') 100 | config_write(section, option, default) 101 | return default 102 | else: 103 | return value 104 | 105 | # Create a config file if not exists 106 | if not os.path.isfile(config_path): 107 | with open(config_path, 'w') as f: 108 | pass 109 | 110 | update_config() 111 | 112 | # Autoupdate if True 113 | if config_value_validator("ttNodes", "auto_update", 'false') == 'true': 114 | try: 115 | with subprocess.Popen(["git", "pull"], cwd=cwd_path, stdout=subprocess.PIPE) as p: 116 | p.wait() 117 | result = p.communicate()[0].decode() 118 | if result != "Already up to date.\n": 119 | print("\033[92m[t ttNodes Updated t]\033[0m") 120 | except: 121 | pass 122 | 123 | # --------- WEB ---------- # 124 | # Remove old web JS folder 125 | web_extension_path = os.path.join(comfy_path, "web", "extensions", "tinyterraNodes") 126 | 127 | if os.path.exists(web_extension_path): 128 | try: 129 | shutil.rmtree(web_extension_path) 130 | except: 131 | print("\033[92m[ttNodes] \033[0;31mFailed to remove old web extension.\033[0m") 132 | 133 | js_files = { 134 | "interface": "enable_interface", 135 | "fullscreen": "enable_fullscreen", 136 | "embedAC": "enable_embed_autocomplete", 137 | "dynamicWidgets": "enable_dynamic_widgets", 138 | } 139 | for js_file, config_key in js_files.items(): 140 | file_path = os.path.join(js_path, f"ttN{js_file}.js") 141 | if config_value_validator("ttNodes", config_key, 'true') == 'false' and os.path.isfile(file_path): 142 | os.rename(file_path, f"{file_path}.disable") 143 | elif config_value_validator("ttNodes", config_key, 'true') == 'true' and os.path.isfile(f"{file_path}.disable"): 144 | os.rename(f"{file_path}.disable", file_path) 145 | 146 | # Enable Dev Nodes if True 147 | if config_value_validator("ttNodes", "enable_dev_nodes", 'true') == 'true': 148 | from .ttNdev import NODE_CLASS_MAPPINGS as ttNdev_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as ttNdev_DISPLAY_NAME_MAPPINGS 149 | else: 150 | ttNdev_CLASS_MAPPINGS = {} 151 | ttNdev_DISPLAY_NAME_MAPPINGS = {} 152 | 153 | # ------- MAPPING ------- # 154 | from .ttNpy.tinyterraNodes import NODE_CLASS_MAPPINGS as TTN_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as TTN_DISPLAY_NAME_MAPPINGS 155 | from .ttNpy.ttNlegacyNodes import NODE_CLASS_MAPPINGS as LEGACY_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as LEGACY_DISPLAY_NAME_MAPPINGS 156 | 157 | NODE_CLASS_MAPPINGS = {**TTN_CLASS_MAPPINGS, **LEGACY_CLASS_MAPPINGS, **ttNdev_CLASS_MAPPINGS} 158 | NODE_DISPLAY_NAME_MAPPINGS = {**TTN_DISPLAY_NAME_MAPPINGS, **LEGACY_DISPLAY_NAME_MAPPINGS, **ttNdev_DISPLAY_NAME_MAPPINGS} 159 | 160 | WEB_DIRECTORY = "./js" 161 | 162 | __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] 163 | -------------------------------------------------------------------------------- /arial.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TinyTerra/ComfyUI_tinyterraNodes/c669e6cff81ba44cce91c9eee3236c1a64725394/arial.ttf -------------------------------------------------------------------------------- /images/icon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TinyTerra/ComfyUI_tinyterraNodes/c669e6cff81ba44cce91c9eee3236c1a64725394/images/icon.jpg -------------------------------------------------------------------------------- /images/tinyterra_pipeSDXL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TinyTerra/ComfyUI_tinyterraNodes/c669e6cff81ba44cce91c9eee3236c1a64725394/images/tinyterra_pipeSDXL.png -------------------------------------------------------------------------------- /images/tinyterra_prefixParsing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TinyTerra/ComfyUI_tinyterraNodes/c669e6cff81ba44cce91c9eee3236c1a64725394/images/tinyterra_prefixParsing.png -------------------------------------------------------------------------------- /images/tinyterra_trueHRFix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TinyTerra/ComfyUI_tinyterraNodes/c669e6cff81ba44cce91c9eee3236c1a64725394/images/tinyterra_trueHRFix.png -------------------------------------------------------------------------------- /images/tinyterra_xyPlot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TinyTerra/ComfyUI_tinyterraNodes/c669e6cff81ba44cce91c9eee3236c1a64725394/images/tinyterra_xyPlot.png -------------------------------------------------------------------------------- /js/ttN.css: -------------------------------------------------------------------------------- 1 | .litegraph.litecontextmenu .litemenu-title .tinyterra-contextmenu-title, 2 | .litegraph.litecontextmenu .litemenu-entry.tinyterra-contextmenu-item { 3 | background-color: #212121 !important; 4 | margin: 0; 5 | display: flex; 6 | flex-direction: row; 7 | align-items: center; 8 | justify-content: start; 9 | } 10 | 11 | .litegraph.litecontextmenu .litemenu-title .tinyterra-contextmenu-title, 12 | .litegraph.litecontextmenu .litemenu-entry.tinyterra-contextmenu-label { 13 | background-color: #000 !important; 14 | margin: 0; 15 | cursor: default; 16 | opacity: 1; 17 | padding: 4px; 18 | font-weight: bold; 19 | } 20 | 21 | 22 | 23 | 24 | /* Dropdown */ 25 | .ttN-dropdown, .ttN-nested-dropdown { 26 | position: relative; 27 | box-sizing: border-box; 28 | background-color: #171717; 29 | box-shadow: 0 4px 4px rgba(255, 255, 255, .25); 30 | padding: 0; 31 | margin: 0; 32 | list-style: none; 33 | z-index: 1000; 34 | overflow: visible; 35 | max-height: fit-content; 36 | max-width: fit-content; 37 | } 38 | 39 | .ttN-dropdown { 40 | position: absolute; 41 | border-radius: 0; 42 | } 43 | 44 | /* Style for final items */ 45 | .ttN-dropdown li.item, .ttN-nested-dropdown li.item { 46 | font-weight: normal; 47 | min-width: max-content; 48 | } 49 | 50 | /* Style for folders (parent items) */ 51 | .ttN-dropdown li.folder, .ttN-nested-dropdown li.folder { 52 | cursor: default; 53 | position: relative; 54 | border-right: 3px solid #005757; 55 | } 56 | 57 | .ttN-dropdown li.folder::after, .ttN-nested-dropdown li.folder::after { 58 | content: ">"; 59 | position: absolute; 60 | right: 2px; 61 | font-weight: normal; 62 | } 63 | 64 | .ttN-dropdown li, .ttN-nested-dropdown li { 65 | padding: 4px 10px; 66 | cursor: pointer; 67 | font-family: system-ui; 68 | font-size: 0.7rem; 69 | position: relative; 70 | } 71 | 72 | /* Style for nested dropdowns */ 73 | .ttN-nested-dropdown { 74 | position: absolute; 75 | top: 0; 76 | left: 100%; 77 | margin: 0; 78 | border: none; 79 | display: none; 80 | } 81 | 82 | .ttN-dropdown li.selected > .ttN-nested-dropdown, 83 | .ttN-nested-dropdown li.selected > .ttN-nested-dropdown { 84 | display: block; 85 | border: none; 86 | } 87 | 88 | .ttN-dropdown li.selected, 89 | .ttN-nested-dropdown li.selected { 90 | background-color: #222222; 91 | border: none; 92 | } -------------------------------------------------------------------------------- /js/ttN.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { tinyterraReloadNode, wait, rebootAPI, getConfig, convertToInput, hideWidget } from "./utils.js"; 3 | import { openFullscreenApp, _setDefaultFullscreenNode } from "./ttNfullscreen.js"; 4 | 5 | class TinyTerra extends EventTarget { 6 | constructor() { 7 | super(); 8 | this.ctrlKey = false 9 | this.altKey = false 10 | this.shiftKey = false 11 | this.downKeys = {} 12 | this.processingMouseDown = false 13 | this.processingMouseUp = false 14 | this.processingMouseMove = false 15 | window.addEventListener("keydown", (e) => { 16 | this.handleKeydown(e) 17 | }) 18 | window.addEventListener("keyup", (e) => { 19 | this.handleKeyup(e) 20 | }) 21 | this.initialiseContextMenu() 22 | this.initialiseNodeMenu() 23 | this.injectTtnCss() 24 | } 25 | async initialiseContextMenu() { 26 | const that = this; 27 | setTimeout(async () => { 28 | const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions; 29 | LGraphCanvas.prototype.getCanvasMenuOptions = function (...args) { 30 | const options = getCanvasMenuOptions.apply(this, [...args]); 31 | options.push(null); 32 | options.push({ 33 | content: `🌏 tinyterraNodes`, 34 | className: "ttN-contextmenu-item ttN-contextmenu-main-item", 35 | submenu: { 36 | options: that.getTinyTerraContextMenuItems(), 37 | }, 38 | }); 39 | 40 | // Remove consecutive null entries 41 | let i = 0; 42 | while (i < options.length) { 43 | if (options[i] === null && (i === 0 || options[i - 1] === null)) { 44 | options.splice(i, 1); 45 | } else { 46 | i++; 47 | } 48 | } 49 | return options; 50 | }; 51 | }, 1000); 52 | } 53 | getTinyTerraContextMenuItems() { 54 | const that = this 55 | return [ 56 | { 57 | content: "🌏 Nodes", 58 | disabled: true, 59 | className: "tinyterra-contextmenu-item tinyterra-contextmenu-label", 60 | }, 61 | { 62 | content: "base", 63 | className: "tinyterra-contextmenu-item", 64 | has_submenu: true, 65 | callback: (...args) => { 66 | that.addTTNodeMenu('base/', args[3], args[2]) 67 | } 68 | }, 69 | { 70 | content: "pipe", 71 | className: "tinyterra-contextmenu-item", 72 | has_submenu: true, 73 | callback: (...args) => { 74 | that.addTTNodeMenu('pipe/', args[3], args[2]) 75 | } 76 | }, 77 | { 78 | content: "xyPlot", 79 | className: "tinyterra-contextmenu-item", 80 | has_submenu: true, 81 | callback: (...args) => { 82 | that.addTTNodeMenu('xyPlot/', args[3], args[2]) 83 | } 84 | }, 85 | { 86 | content: "text", 87 | className: "tinyterra-contextmenu-item", 88 | has_submenu: true, 89 | callback: (...args) => { 90 | that.addTTNodeMenu('text/', args[3], args[2]) 91 | } 92 | }, 93 | { 94 | content: "image", 95 | className: "tinyterra-contextmenu-item", 96 | has_submenu: true, 97 | callback: (...args) => { 98 | that.addTTNodeMenu('image/', args[3], args[2]) 99 | } 100 | }, 101 | { 102 | content: "util", 103 | className: "tinyterra-contextmenu-item", 104 | has_submenu: true, 105 | callback: (...args) => { 106 | that.addTTNodeMenu('util/', args[3], args[2]) 107 | } 108 | }, 109 | { 110 | content: "🌏 Add Group", 111 | disabled: true, 112 | className: "tinyterra-contextmenu-item tinyterra-contextmenu-label", 113 | }, 114 | { 115 | content: "Basic Sampling", 116 | className: "tinyterra-contextmenu-item", 117 | has_submenu: true, 118 | callback : function(value, event, mouseEvent, contextMenu){ 119 | that.addGroupMenu('basic', contextMenu, mouseEvent) 120 | } 121 | }, 122 | { 123 | content: "Upscaling", 124 | className: "tinyterra-contextmenu-item", 125 | has_submenu: true, 126 | callback : function(value, event, mouseEvent, contextMenu){ 127 | that.addGroupMenu('upscale', contextMenu, mouseEvent) 128 | } 129 | }, 130 | { 131 | content: "xyPlotting", 132 | className: "tinyterra-contextmenu-item", 133 | has_submenu: true, 134 | callback : function(value, event, mouseEvent, contextMenu){ 135 | that.addGroupMenu('xyPlot', contextMenu, mouseEvent) 136 | } 137 | }, 138 | { 139 | content: "🌏 Extras", 140 | disabled: true, 141 | className: "tinyterra-contextmenu-item tinyterra-contextmenu-label", 142 | }, 143 | // { 144 | // content: "⚙️ Settings (tinyterra)", 145 | // disabled: true, //!!this.settingsDialog, 146 | // className: "tinyterra-contextmenu-item", 147 | // callback: (...args) => { 148 | // this.settingsDialog = new tinyterraConfigDialog().show(); 149 | // this.settingsDialog.addEventListener("close", (e) => { 150 | // this.settingsDialog = null; 151 | // }); 152 | // }, 153 | // }, 154 | { 155 | content: "🛑 Reboot Comfy", 156 | className: "tinyterra-contextmenu-item", 157 | callback: (...args) => { 158 | rebootAPI(); 159 | wait(1000).then(() => { 160 | window.location.reload(); 161 | }); 162 | } 163 | }, 164 | { 165 | content: "⭐ Star on Github", 166 | className: "tinyterra-contextmenu-item", 167 | callback: (...args) => { 168 | window.open("https://github.com/TinyTerra/ComfyUI_tinyterraNodes", "_blank"); 169 | }, 170 | }, 171 | { 172 | content: "☕ Support TinyTerra", 173 | className: "tinyterra-contextmenu-item", 174 | callback: (...args) => { 175 | window.open("https://buymeacoffee.com/tinyterra", "_blank"); 176 | }, 177 | }, 178 | 179 | ]; 180 | } 181 | addNode = async (node, pos) => { 182 | var canvas = LGraphCanvas.active_canvas; 183 | canvas.graph.beforeChange(); 184 | var node = LiteGraph.createNode(node); 185 | if (node) { 186 | node.pos = pos; 187 | canvas.graph.add(node); 188 | } 189 | canvas.graph.afterChange(); 190 | return node 191 | } 192 | addGroup = async (contextMenu, nodes) => { 193 | var first_event = contextMenu.getFirstEvent(); 194 | var canvas = LGraphCanvas.active_canvas; 195 | var canvasOffset = canvas.convertEventToCanvasOffset(first_event); 196 | 197 | // Create Nodes 198 | for (const nodeData of Object.values(nodes)) { 199 | var node = await this.addNode(nodeData.nodeType, canvasOffset); 200 | nodeData.graphNode = node; 201 | canvasOffset = [canvasOffset[0] + nodeData.width + 10, canvasOffset[1]]; 202 | } 203 | 204 | // Handle Widget Changes 205 | for (const nodeData of Object.values(nodes)) { 206 | var node = nodeData.graphNode; 207 | if (nodeData.widgets) { 208 | for (const [widget, value] of Object.entries(nodeData.widgets)) { 209 | if (value == 'toInput') { 210 | const config = getConfig(widget, node) 211 | convertToInput(node, node.widgets.find((w) => w.name === widget), config); 212 | } else { 213 | if (node) { 214 | node.widgets.find((w) => w.name === widget).value = value 215 | } 216 | } 217 | } 218 | } 219 | } 220 | 221 | // Handle Connections 222 | for (const nodeData of Object.values(nodes)) { 223 | var node = nodeData.graphNode; 224 | if (nodeData.connections) { 225 | for (const c of nodeData.connections) { 226 | node.connect(parseInt(c[0]), nodes[c[1]].graphNode.id, c[2]); 227 | } 228 | } 229 | } 230 | } 231 | addTTNodeMenu(category, prev_menu, e, callback=null) { 232 | var canvas = LGraphCanvas.active_canvas; 233 | var ref_window = canvas.getCanvasWindow(); 234 | var graph = canvas.graph; 235 | const base_category = '🌏 tinyterra/' + category 236 | 237 | var entries = []; 238 | 239 | var nodes = LiteGraph.getNodeTypesInCategory(base_category.slice(0, -1), canvas.filter || graph.filter ); 240 | nodes.map(function(node){ 241 | if (node.skip_list) 242 | return; 243 | 244 | var entry = { 245 | value: node.type, 246 | content: node.title, 247 | className: "tinyterra-contextmenu-item", 248 | has_submenu: false, 249 | callback : function(value, event, mouseEvent, contextMenu){ 250 | var first_event = contextMenu.getFirstEvent(); 251 | canvas.graph.beforeChange(); 252 | var node = LiteGraph.createNode(value.value); 253 | if (node) { 254 | node.pos = canvas.convertEventToCanvasOffset(first_event); 255 | canvas.graph.add(node); 256 | } 257 | if(callback) 258 | callback(node); 259 | canvas.graph.afterChange(); 260 | } 261 | } 262 | 263 | entries.push(entry); 264 | }); 265 | 266 | new LiteGraph.ContextMenu( entries, { event: e, parentMenu: prev_menu }, ref_window ); 267 | } 268 | addGroupMenu(group, prev_menu, e) { 269 | const that = this; 270 | var canvas = LGraphCanvas.active_canvas; 271 | var ref_window = canvas.getCanvasWindow(); 272 | let entries; 273 | switch (group) { 274 | case "basic": 275 | entries = [ 276 | { content: "Base ttN", 277 | className: "tinyterra-contextmenu-item", 278 | callback : async function(value, event, mouseEvent, contextMenu){ 279 | const nodes = { 280 | 'Loader': { 281 | nodeType: 'ttN tinyLoader', 282 | graphNode: null, 283 | width: 315, 284 | connections: [ 285 | [0, 'Conditioning', 'model'], 286 | [1, 'KSampler', 'latent'], 287 | [2, 'KSampler', 'vae'], 288 | [3, 'Conditioning', 'clip'], 289 | ], 290 | }, 291 | 'Conditioning': { 292 | nodeType: 'ttN conditioning', 293 | graphNode: null, 294 | width: 400, 295 | connections: [ 296 | [0, 'KSampler', 'model'], 297 | [1, 'KSampler', 'positive'], 298 | [2, 'KSampler', 'negative'], 299 | [3, 'KSampler', 'clip'], 300 | ], 301 | }, 302 | 'KSampler': { 303 | nodeType: 'ttN KSampler_v2', 304 | graphNode: null, 305 | width: 262, 306 | widgets: { 307 | image_output: 'Preview' 308 | } 309 | } 310 | } 311 | that.addGroup(contextMenu, nodes) 312 | } 313 | }, 314 | { content: "Pipe Basic", 315 | className: "tinyterra-contextmenu-item", 316 | callback : async function(value, event, mouseEvent, contextMenu){ 317 | const nodes = { 318 | 'Loader': { 319 | nodeType: 'ttN pipeLoader_v2', 320 | graphNode: null, 321 | width: 315, 322 | connections: [ 323 | [0, 'KSampler', 'pipe'] 324 | ], 325 | }, 326 | 'KSampler': { 327 | nodeType: 'ttN pipeKSampler_v2', 328 | graphNode: null, 329 | width: 262, 330 | widgets: { 331 | image_output: 'Preview' 332 | } 333 | } 334 | } 335 | that.addGroup(contextMenu, nodes) 336 | } 337 | }, 338 | { content: "Pipe SDXL", 339 | className: "tinyterra-contextmenu-item", 340 | callback : async function(value, event, mouseEvent, contextMenu){ 341 | const nodes = { 342 | 'Loader': { 343 | nodeType: 'ttN pipeLoaderSDXL_v2', 344 | graphNode: null, 345 | width: 365, 346 | connections: [ 347 | [0, 'KSampler', 'sdxl_pipe'] 348 | ], 349 | }, 350 | 'KSampler': { 351 | nodeType: 'ttN pipeKSamplerSDXL_v2', 352 | graphNode: null, 353 | width: 365, 354 | widgets: { 355 | image_output: 'Preview' 356 | } 357 | } 358 | } 359 | that.addGroup(contextMenu, nodes) 360 | } 361 | }, 362 | ]; 363 | break; 364 | 365 | case "upscale": 366 | entries = [ 367 | { content: "Base upscale", 368 | className: "tinyterra-contextmenu-item", 369 | callback : async function(value, event, mouseEvent, contextMenu){ 370 | const nodes = { 371 | 'Loader': { 372 | nodeType: 'ttN tinyLoader', 373 | graphNode: null, 374 | width: 315, 375 | connections: [ 376 | [0, 'Conditioning', 'model'], 377 | [1, 'KSampler', 'latent'], 378 | [2, 'KSampler', 'vae'], 379 | [3, 'Conditioning', 'clip'], 380 | ], 381 | }, 382 | 'Conditioning': { 383 | nodeType: 'ttN conditioning', 384 | graphNode: null, 385 | width: 400, 386 | connections: [ 387 | [0, 'KSampler', 'model'], 388 | [1, 'KSampler', 'positive'], 389 | [2, 'KSampler', 'negative'], 390 | [3, 'KSampler', 'clip'], 391 | ], 392 | }, 393 | 'KSampler': { 394 | nodeType: 'ttN KSampler_v2', 395 | graphNode: null, 396 | width: 262, 397 | connections: [ 398 | [0, 'KSampler2', 'model'], 399 | [1, 'KSampler2', 'positive'], 400 | [2, 'KSampler2', 'negative'], 401 | [3, 'KSampler2', 'latent'], 402 | [4, 'KSampler2', 'vae'], 403 | [5, 'KSampler2', 'clip'], 404 | [6, 'KSampler2', 'input_image_override'] 405 | ], 406 | widgets: { 407 | image_output: 'Preview', 408 | } 409 | }, 410 | 'KSampler2': { 411 | nodeType: 'ttN KSampler_v2', 412 | graphNode: null, 413 | width: 262, 414 | widgets: { 415 | upscale_method: '[hiresFix] nearest-exact', 416 | image_output: 'Preview', 417 | denoise: 0.5, 418 | steps: 15 419 | } 420 | }, 421 | } 422 | that.addGroup(contextMenu, nodes) 423 | } 424 | }, 425 | { content: "Pipe Upscale", 426 | className: "tinyterra-contextmenu-item", 427 | callback : async function(value, event, mouseEvent, contextMenu){ 428 | const nodes = { 429 | 'loader1': { 430 | nodeType: 'ttN pipeLoader_v2', 431 | graphNode: null, 432 | width: 315, 433 | connections: [ 434 | [0, 'ksampler', 'pipe'] 435 | ], 436 | }, 437 | 'ksampler': { 438 | nodeType: 'ttN pipeKSampler_v2', 439 | graphNode: null, 440 | width: 262, 441 | connections: [ 442 | [0, 'ksampler2', 'pipe'] 443 | ], 444 | widgets: { 445 | image_output: 'Preview' 446 | }, 447 | }, 448 | 'ksampler2': { 449 | nodeType: 'ttN pipeKSampler_v2', 450 | graphNode: null, 451 | width: 262, 452 | widgets: { 453 | upscale_method: '[hiresFix] nearest-exact', 454 | denoise: 0.5, 455 | seed: 'toInput', 456 | image_output: 'Preview' 457 | } 458 | } 459 | } 460 | that.addGroup(contextMenu, nodes) 461 | } 462 | }, 463 | ]; 464 | break; 465 | 466 | case "xyPlot": 467 | entries = [ 468 | { content: "Base xyPlot", 469 | className: "tinyterra-contextmenu-item", 470 | callback : async function(value, event, mouseEvent, contextMenu){ 471 | const nodes = { 472 | 'Loader': { 473 | nodeType: 'ttN tinyLoader', 474 | graphNode: null, 475 | width: 315, 476 | connections: [ 477 | [0, 'Conditioning', 'model'], 478 | [1, 'KSampler', 'latent'], 479 | [2, 'KSampler', 'vae'], 480 | [3, 'Conditioning', 'clip'], 481 | ], 482 | }, 483 | 'Conditioning': { 484 | nodeType: 'ttN conditioning', 485 | graphNode: null, 486 | width: 400, 487 | connections: [ 488 | [0, 'KSampler', 'model'], 489 | [1, 'KSampler', 'positive'], 490 | [2, 'KSampler', 'negative'], 491 | [3, 'KSampler', 'clip'], 492 | ], 493 | }, 494 | 'xyPlot': { 495 | nodeType: 'ttN advanced xyPlot', 496 | graphNode: null, 497 | width: 400, 498 | connections: [ 499 | [0, 'KSampler', 'adv_xyPlot'], 500 | ], 501 | }, 502 | 'KSampler': { 503 | nodeType: 'ttN KSampler_v2', 504 | graphNode: null, 505 | width: 262, 506 | widgets: { 507 | image_output: 'Preview' 508 | } 509 | } 510 | } 511 | that.addGroup(contextMenu, nodes) 512 | } 513 | }, 514 | { content: "Pipe xyPlot", 515 | className: "tinyterra-contextmenu-item", 516 | callback : async function(value, event, mouseEvent, contextMenu){ 517 | const nodes = { 518 | 'Loader': { 519 | nodeType: 'ttN pipeLoader_v2', 520 | graphNode: null, 521 | width: 315, 522 | connections: [ 523 | [0, 'KSampler', 'pipe'], 524 | ], 525 | }, 526 | 'xyPlot': { 527 | nodeType: 'ttN advanced xyPlot', 528 | graphNode: null, 529 | width: 400, 530 | connections: [ 531 | [0, 'KSampler', 'adv_xyPlot'], 532 | ], 533 | }, 534 | 'KSampler': { 535 | nodeType: 'ttN pipeKSampler_v2', 536 | graphNode: null, 537 | width: 262, 538 | widgets: { 539 | image_output: 'Preview' 540 | } 541 | } 542 | } 543 | that.addGroup(contextMenu, nodes) 544 | } 545 | }, 546 | ] 547 | } 548 | new LiteGraph.ContextMenu( entries, { event: e, parentMenu: prev_menu }, ref_window ); 549 | } 550 | async initialiseNodeMenu() { 551 | const that = this; 552 | setTimeout(async () => { 553 | const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions; 554 | LGraphCanvas.prototype.getNodeMenuOptions = function (node) { 555 | const options = getNodeMenuOptions.apply(this, arguments); 556 | node.setDirtyCanvas(true, true); 557 | const ttNoptions = that.getTinyTerraNodeMenuItems(node) 558 | options.splice(options.length - 1, 0, ...ttNoptions, null); 559 | 560 | return options; 561 | }; 562 | },500) 563 | } 564 | getTinyTerraNodeMenuItems(node) { 565 | return [ 566 | { 567 | content: "🌏 Fullscreen", 568 | callback: () => { openFullscreenApp(node) } 569 | }, 570 | { 571 | content: "🌏 Set Default Fullscreen Node", 572 | callback: _setDefaultFullscreenNode 573 | }, 574 | { 575 | content: "🌏 Clear Default Fullscreen Node", 576 | callback: function () { 577 | sessionStorage.removeItem('Comfy.Settings.ttN.default_fullscreen_node'); 578 | } 579 | }, 580 | null, 581 | { 582 | content: "🌏 Default Node BG Color", 583 | has_submenu: true, 584 | callback: LGraphCanvas.ttNsetDefaultBGColor 585 | }, 586 | { 587 | content: "🌏 Node Dimensions", 588 | callback: () => { LGraphCanvas.prototype.ttNsetNodeDimension(node); } 589 | }, 590 | { 591 | content: "🌏 Reload Node", 592 | callback: () => { 593 | const active_canvas = LGraphCanvas.active_canvas; 594 | if (!active_canvas.selected_nodes || Object.keys(active_canvas.selected_nodes).length <= 1) { 595 | tinyterraReloadNode(node); 596 | } else { 597 | for (var i in active_canvas.selected_nodes) { 598 | tinyterraReloadNode(active_canvas.selected_nodes[i]); 599 | } 600 | } 601 | } 602 | }, 603 | ] 604 | } 605 | handleKeydown(e) { 606 | this.ctrlKey = !!e.ctrlKey 607 | this.altKey = !!e.altKey 608 | this.shiftKey = !!e.shiftKey 609 | this.downKeys[e.key.toLocaleUpperCase()] = true 610 | this.downKeys["^" + e.key.toLocaleUpperCase()] = true 611 | } 612 | handleKeyup(e) { 613 | this.ctrlKey = !!e.ctrlKey 614 | this.altKey = !!e.altKey 615 | this.shiftKey = !!e.shiftKey 616 | this.downKeys[e.key.toLocaleUpperCase()] = false 617 | this.downKeys["^" + e.key.toLocaleUpperCase()] = false 618 | } 619 | injectTtnCss() { 620 | const link = document.createElement("link"); 621 | link.rel = "stylesheet"; 622 | link.type = "text/css"; 623 | link.href = "extensions/ComfyUI_tinyterraNodes/ttN.css"; 624 | 625 | link.onerror = function () { 626 | if (this.href.includes("comfyui_tinyterranodes")) { 627 | console.error("tinyterraNodes: Failed to load CSS file. Please check nodepack folder name."); 628 | return; 629 | } 630 | this.href = "extensions/comfyui_tinyterranodes/ttN.css" 631 | } 632 | document.head.appendChild(link); 633 | } 634 | } 635 | 636 | export const tinyterra = new TinyTerra(); 637 | window.tinyterra = tinyterra; 638 | 639 | app.registerExtension({ 640 | name: "comfy.ttN", 641 | setup() { 642 | if (!localStorage.getItem("ttN.pysssss")) { 643 | const ttNckpts = ['ttN pipeLoader_v2', "ttN pipeLoaderSDXL_v2", "ttN tinyLoader"] 644 | let pysCheckpoints = app.ui.settings.getSettingValue('pysssss.ModelInfo.CheckpointNodes') 645 | if (pysCheckpoints) { 646 | for (let ckpt of ttNckpts) { 647 | if (!pysCheckpoints.includes(ckpt)) { 648 | pysCheckpoints = `${pysCheckpoints},${ckpt}` 649 | } 650 | } 651 | app.ui.settings.setSettingValue('pysssss.ModelInfo.CheckpointNodes', pysCheckpoints) 652 | } 653 | 654 | const ttNloras = ['ttN KSampler_v2', 'ttN pipeKSampler_v2', 'ttN pipeKSamplerAdvanced_v2', 'ttN pipeKSamplerSDXL_v2', ] 655 | let pysLoras = app.ui.settings.getSettingValue('pysssss.ModelInfo.LoraNodes') 656 | if (pysLoras) { 657 | for (let lora of ttNloras) { 658 | if (!pysLoras.includes(lora)) { 659 | pysLoras = `${pysLoras},${lora}` 660 | } 661 | } 662 | app.ui.settings.setSettingValue('pysssss.ModelInfo.LoraNodes', pysLoras) 663 | } 664 | if (pysCheckpoints && pysLoras) { 665 | localStorage.setItem("ttN.pysssss", true) 666 | } 667 | } 668 | }, 669 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 670 | if (nodeData.name.startsWith("ttN")) { 671 | const origOnConfigure = nodeType.prototype.onConfigure; 672 | nodeType.prototype.onConfigure = function () { 673 | const r = origOnConfigure ? origOnConfigure.apply(this, arguments) : undefined; 674 | let nodeVersion = nodeData.input.hidden?.ttNnodeVersion ? nodeData.input.hidden.ttNnodeVersion : null; 675 | nodeType.ttNnodeVersion = nodeVersion; 676 | this.properties['ttNnodeVersion'] = this.properties['ttNnodeVersion'] ? this.properties['ttNnodeVersion'] : nodeVersion; 677 | if ((this.properties['ttNnodeVersion']?.split(".")[0] !== nodeVersion?.split(".")[0]) || (this.properties['ttNnodeVersion']?.split(".")[1] !== nodeVersion?.split(".")[1])) { 678 | if (!this.properties['origVals']) { 679 | this.properties['origVals'] = { bgcolor: this.bgcolor, color: this.color, title: this.title } 680 | } 681 | this.bgcolor = "#e76066"; 682 | this.color = "#ff0b1e"; 683 | this.title = this.title.includes("Node Version Mismatch") ? this.title : this.title + " - Node Version Mismatch" 684 | } else if (this.properties['origVals']) { 685 | this.bgcolor = this.properties.origVals.bgcolor; 686 | this.color = this.properties.origVals.color; 687 | this.title = this.properties.origVals.title; 688 | delete this.properties['origVals'] 689 | } 690 | return r; 691 | }; 692 | } 693 | }, 694 | nodeCreated(node) { 695 | if (["pipeLoader", "pipeLoaderSDXL"].includes(node.constructor.title)) { 696 | for (let widget of node.widgets) { 697 | if (widget.name === "control_after_generate") { 698 | widget.value = "fixed" 699 | } 700 | } 701 | } 702 | } 703 | }); 704 | -------------------------------------------------------------------------------- /js/ttNdropdown.js: -------------------------------------------------------------------------------- 1 | // ttN Dropdown 2 | let activeDropdown = null; 3 | 4 | class Dropdown { 5 | constructor(inputEl, options, onSelect, isDict, manualOffset, hostElement) { 6 | this.dropdown = document.createElement('ul'); 7 | this.dropdown.setAttribute('role', 'listbox'); 8 | this.dropdown.classList.add('ttN-dropdown'); 9 | this.selectedIndex = -1; 10 | this.inputEl = inputEl; 11 | this.options = options; 12 | this.onSelect = onSelect; 13 | this.isDict = isDict; 14 | this.manualOffsetX = manualOffset[0]; 15 | this.manualOffsetY = manualOffset[1]; 16 | this.hostElement = hostElement; 17 | 18 | this.focusedDropdown = this.dropdown; 19 | 20 | this.buildDropdown(); 21 | 22 | this.onKeyDownBound = this.onKeyDown.bind(this); 23 | this.onWheelBound = this.onWheel.bind(this); 24 | this.onClickBound = this.onClick.bind(this); 25 | 26 | this.addEventListeners(); 27 | } 28 | 29 | buildDropdown() { 30 | if (this.isDict) { 31 | this.buildNestedDropdown(this.options, this.dropdown); 32 | } else { 33 | this.options.forEach((suggestion, index) => { 34 | this.addListItem(suggestion, index, this.dropdown); 35 | }); 36 | } 37 | 38 | const inputRect = this.inputEl.getBoundingClientRect(); 39 | if (isNaN(this.manualOffsetX) && this.manualOffsetX.includes('%')) { 40 | this.manualOffsetX = (inputRect.height * (parseInt(this.manualOffsetX) / 100)) 41 | } 42 | if (isNaN(this.manualOffsetY) && this.manualOffsetY.includes('%')) { 43 | this.manualOffsetY = (inputRect.width * (parseInt(this.manualOffsetY) / 100)) 44 | } 45 | this.dropdown.style.top = (inputRect.top + inputRect.height - this.manualOffsetX) + 'px'; 46 | this.dropdown.style.left = (inputRect.left + inputRect.width - this.manualOffsetY) + 'px'; 47 | 48 | this.hostElement.appendChild(this.dropdown); 49 | 50 | activeDropdown = this; 51 | } 52 | 53 | buildNestedDropdown(dictionary, parentElement, currentPath = '') { 54 | let index = 0; 55 | Object.keys(dictionary).forEach((key) => { 56 | let extra_data; 57 | const item = dictionary[key]; 58 | if (typeof item === 'string') { extra_data = item; } 59 | 60 | let fullPath = currentPath ? `${currentPath}/${key}` : key; 61 | if (extra_data) { fullPath = `${fullPath}###${extra_data}`; } 62 | 63 | if (typeof item === "object" && item !== null) { 64 | const nestedDropdown = document.createElement('ul'); 65 | nestedDropdown.setAttribute('role', 'listbox'); 66 | nestedDropdown.classList.add('ttN-nested-dropdown'); 67 | const parentListItem = document.createElement('li'); 68 | parentListItem.classList.add('folder'); 69 | parentListItem.textContent = key; 70 | parentListItem.appendChild(nestedDropdown); 71 | parentListItem.addEventListener('mouseover', this.onMouseOver.bind(this, index, parentElement)); 72 | parentElement.appendChild(parentListItem); 73 | this.buildNestedDropdown(item, nestedDropdown, fullPath); 74 | index = index + 1; 75 | } else { 76 | const listItem = document.createElement('li'); 77 | listItem.classList.add('item'); 78 | listItem.setAttribute('role', 'option'); 79 | listItem.textContent = key; 80 | listItem.addEventListener('mouseover', this.onMouseOver.bind(this, index, parentElement)); 81 | listItem.addEventListener('mousedown', (e) => this.onMouseDown(key, e, fullPath)); 82 | parentElement.appendChild(listItem); 83 | index = index + 1; 84 | } 85 | }); 86 | } 87 | 88 | addListItem(item, index, parentElement) { 89 | const listItem = document.createElement('li'); 90 | listItem.setAttribute('role', 'option'); 91 | listItem.textContent = item; 92 | listItem.addEventListener('mouseover', (e) => this.onMouseOver(index)); 93 | listItem.addEventListener('mousedown', (e) => this.onMouseDown(item, e)); 94 | parentElement.appendChild(listItem); 95 | } 96 | 97 | addEventListeners() { 98 | document.addEventListener('keydown', this.onKeyDownBound); 99 | this.dropdown.addEventListener('wheel', this.onWheelBound); 100 | document.addEventListener('click', this.onClickBound); 101 | } 102 | 103 | removeEventListeners() { 104 | document.removeEventListener('keydown', this.onKeyDownBound); 105 | this.dropdown.removeEventListener('wheel', this.onWheelBound); 106 | document.removeEventListener('click', this.onClickBound); 107 | } 108 | 109 | onMouseOver(index, parentElement=null) { 110 | if (parentElement) { 111 | this.focusedDropdown = parentElement; 112 | } 113 | this.selectedIndex = index; 114 | this.updateSelection(); 115 | } 116 | 117 | onMouseOut() { 118 | this.selectedIndex = -1; 119 | this.updateSelection(); 120 | } 121 | 122 | onMouseDown(suggestion, event, fullPath='') { 123 | event.preventDefault(); 124 | this.onSelect(suggestion, fullPath); 125 | this.dropdown.remove(); 126 | this.removeEventListeners(); 127 | } 128 | 129 | onKeyDown(event) { 130 | const enterKeyCode = 13; 131 | const escKeyCode = 27; 132 | const arrowUpKeyCode = 38; 133 | const arrowDownKeyCode = 40; 134 | const arrowRightKeyCode = 39; 135 | const arrowLeftKeyCode = 37; 136 | const tabKeyCode = 9; 137 | 138 | const items = Array.from(this.focusedDropdown.children); 139 | const selectedItem = items[this.selectedIndex]; 140 | 141 | if (activeDropdown) { 142 | if (event.keyCode === arrowUpKeyCode) { 143 | event.preventDefault(); 144 | this.selectedIndex = Math.max(0, this.selectedIndex - 1); 145 | this.updateSelection(); 146 | } 147 | 148 | else if (event.keyCode === arrowDownKeyCode) { 149 | event.preventDefault(); 150 | this.selectedIndex = Math.min(items.length - 1, this.selectedIndex + 1); 151 | this.updateSelection(); 152 | } 153 | 154 | else if (event.keyCode === arrowRightKeyCode && selectedItem) { 155 | event.preventDefault(); 156 | if (selectedItem.classList.contains('folder')) { 157 | const nestedDropdown = selectedItem.querySelector('.ttN-nested-dropdown'); 158 | if (nestedDropdown) { 159 | this.focusedDropdown = nestedDropdown; 160 | this.selectedIndex = 0; 161 | this.updateSelection(); 162 | } 163 | } 164 | } 165 | 166 | else if (event.keyCode === arrowLeftKeyCode && this.focusedDropdown !== this.dropdown) { 167 | const parentDropdown = this.focusedDropdown.closest('.ttN-dropdown, .ttN-nested-dropdown').parentNode.closest('.ttN-dropdown, .ttN-nested-dropdown'); 168 | if (parentDropdown) { 169 | this.focusedDropdown = parentDropdown; 170 | this.selectedIndex = Array.from(parentDropdown.children).indexOf(this.focusedDropdown.parentNode); 171 | this.updateSelection(); 172 | } 173 | } 174 | 175 | else if ((event.keyCode === enterKeyCode || event.keyCode === tabKeyCode) && this.selectedIndex >= 0) { 176 | event.preventDefault(); 177 | if (selectedItem.classList.contains('item')) { 178 | this.onSelect(items[this.selectedIndex].textContent); 179 | this.dropdown.remove(); 180 | this.removeEventListeners(); 181 | } 182 | 183 | const nestedDropdown = selectedItem.querySelector('.ttN-nested-dropdown'); 184 | if (nestedDropdown) { 185 | this.focusedDropdown = nestedDropdown; 186 | this.selectedIndex = 0; 187 | this.updateSelection(); 188 | } 189 | } 190 | 191 | else if (event.keyCode === escKeyCode) { 192 | this.dropdown.remove(); 193 | this.removeEventListeners(); 194 | } 195 | } 196 | } 197 | 198 | onWheel(event) { 199 | const top = parseInt(this.dropdown.style.top); 200 | if (localStorage.getItem("Comfy.Settings.Comfy.InvertMenuScrolling")) { 201 | this.dropdown.style.top = (top + (event.deltaY < 0 ? 10 : -10)) + "px"; 202 | } else { 203 | this.dropdown.style.top = (top + (event.deltaY < 0 ? -10 : 10)) + "px"; 204 | } 205 | } 206 | 207 | onClick(event) { 208 | if (!this.dropdown.contains(event.target) && event.target !== this.inputEl) { 209 | this.dropdown.remove(); 210 | this.removeEventListeners(); 211 | } 212 | } 213 | 214 | updateSelection() { 215 | if (!this.focusedDropdown.children) { 216 | this.dropdown.classList.add('selected'); 217 | } else { 218 | Array.from(this.focusedDropdown.children).forEach((li, index) => { 219 | if (index === this.selectedIndex) { 220 | li.classList.add('selected'); 221 | } else { 222 | li.classList.remove('selected'); 223 | } 224 | }); 225 | } 226 | } 227 | } 228 | 229 | export function ttN_RemoveDropdown() { 230 | if (activeDropdown) { 231 | activeDropdown.removeEventListeners(); 232 | activeDropdown.dropdown.remove(); 233 | activeDropdown = null; 234 | } 235 | } 236 | 237 | export function ttN_CreateDropdown(inputEl, options, onSelect, isDict = false, manualOffset = [10,'100%'], hostElement = document.body) { 238 | ttN_RemoveDropdown(); 239 | new Dropdown(inputEl, options, onSelect, isDict, manualOffset, hostElement); 240 | } -------------------------------------------------------------------------------- /js/ttNdynamicWidgets.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | 3 | let origProps = {}; 4 | 5 | const findWidgetByName = (node, name) => node.widgets.find((w) => w.name === name); 6 | 7 | const doesInputWithNameExist = (node, name) => node.inputs ? node.inputs.some((input) => input.name === name) : false; 8 | 9 | function updateNodeHeight(node) { 10 | node.setSize([node.size[0], node.computeSize()[1]]); 11 | app.canvas.dirty_canvas = true; 12 | } 13 | 14 | function toggleWidget(node, widget, show = false, suffix = "") { 15 | if (!widget || doesInputWithNameExist(node, widget.name)) return; 16 | if (!origProps[widget.name]) { 17 | origProps[widget.name] = { origType: widget.type, origComputeSize: widget.computeSize, origComputedHeight: widget.computedHeight }; 18 | } 19 | const origSize = node.size; 20 | 21 | widget.type = show ? origProps[widget.name].origType : "ttNhidden" + suffix; 22 | widget.computeSize = show ? origProps[widget.name].origComputeSize : () => [0, -4]; 23 | widget.computedHeight = show ? origProps[widget.name].origComputedHeight : 0; 24 | 25 | widget.linkedWidgets?.forEach(w => toggleWidget(node, w, ":" + widget.name, show)); 26 | 27 | const height = show ? Math.max(node.computeSize()[1], origSize[1]) : node.size[1]; 28 | node.setSize([node.size[0], height]); 29 | app.canvas.dirty_canvas = true 30 | } 31 | 32 | function widgetLogic(node, widget) { 33 | switch (widget.name) { 34 | case 'lora_name': 35 | if (widget.value === "None") { 36 | toggleWidget(node, findWidgetByName(node, 'lora_model_strength')) 37 | toggleWidget(node, findWidgetByName(node, 'lora_clip_strength')) 38 | toggleWidget(node, findWidgetByName(node, 'lora_strength')) 39 | } else { 40 | toggleWidget(node, findWidgetByName(node, 'lora_model_strength'), true) 41 | toggleWidget(node, findWidgetByName(node, 'lora_clip_strength'), true) 42 | toggleWidget(node, findWidgetByName(node, 'lora_strength'), true) 43 | } 44 | break; 45 | 46 | case 'lora1_name': 47 | if (widget.value === "None") { 48 | toggleWidget(node, findWidgetByName(node, 'lora1_model_strength')) 49 | toggleWidget(node, findWidgetByName(node, 'lora1_clip_strength')) 50 | } else { 51 | toggleWidget(node, findWidgetByName(node, 'lora1_model_strength'), true) 52 | toggleWidget(node, findWidgetByName(node, 'lora1_clip_strength'), true) 53 | } 54 | break; 55 | 56 | case 'lora2_name': 57 | if (widget.value === "None") { 58 | toggleWidget(node, findWidgetByName(node, 'lora2_model_strength')) 59 | toggleWidget(node, findWidgetByName(node, 'lora2_clip_strength')) 60 | } else { 61 | toggleWidget(node, findWidgetByName(node, 'lora2_model_strength'), true) 62 | toggleWidget(node, findWidgetByName(node, 'lora2_clip_strength'), true) 63 | } 64 | break; 65 | 66 | case 'lora3_name': 67 | if (widget.value === "None") { 68 | toggleWidget(node, findWidgetByName(node, 'lora3_model_strength')) 69 | toggleWidget(node, findWidgetByName(node, 'lora3_clip_strength')) 70 | } else { 71 | toggleWidget(node, findWidgetByName(node, 'lora3_model_strength'), true) 72 | toggleWidget(node, findWidgetByName(node, 'lora3_clip_strength'), true) 73 | } 74 | break; 75 | 76 | case 'refiner_ckpt_name': 77 | let refiner_lora1 = findWidgetByName(node, 'refiner_lora1_name')?.value 78 | let refiner_lora2 = findWidgetByName(node, 'refiner_lora2_name')?.value 79 | if (widget.value === "None") { 80 | toggleWidget(node, findWidgetByName(node, 'refiner_vae_name')) 81 | toggleWidget(node, findWidgetByName(node, 'refiner_config_name')) 82 | toggleWidget(node, findWidgetByName(node, 'refiner_clip_skip')) 83 | toggleWidget(node, findWidgetByName(node, 'refiner_loras')) 84 | toggleWidget(node, findWidgetByName(node, 'positive_ascore')) 85 | toggleWidget(node, findWidgetByName(node, 'negative_ascore')) 86 | 87 | toggleWidget(node, findWidgetByName(node, 'refiner_lora1_name')) 88 | toggleWidget(node, findWidgetByName(node, 'refiner_lora1_model_strength')) 89 | toggleWidget(node, findWidgetByName(node, 'refiner_lora1_clip_strength')) 90 | toggleWidget(node, findWidgetByName(node, 'refiner_lora2_name')) 91 | toggleWidget(node, findWidgetByName(node, 'refiner_lora2_model_strength')) 92 | toggleWidget(node, findWidgetByName(node, 'refiner_lora2_clip_strength')) 93 | } else { 94 | toggleWidget(node, findWidgetByName(node, 'refiner_vae_name'), true) 95 | toggleWidget(node, findWidgetByName(node, 'refiner_config_name'), true) 96 | toggleWidget(node, findWidgetByName(node, 'refiner_clip_skip'), true) 97 | toggleWidget(node, findWidgetByName(node, 'refiner_loras'), true) 98 | toggleWidget(node, findWidgetByName(node, 'positive_ascore'), true) 99 | toggleWidget(node, findWidgetByName(node, 'negative_ascore'), true) 100 | toggleWidget(node, findWidgetByName(node, 'refiner_lora1_name'), true) 101 | if (refiner_lora1 !== "None") { 102 | toggleWidget(node, findWidgetByName(node, 'refiner_lora1_model_strength'), true) 103 | toggleWidget(node, findWidgetByName(node, 'refiner_lora1_clip_strength'), true) 104 | } 105 | toggleWidget(node, findWidgetByName(node, 'refiner_lora2_name'), true) 106 | if (refiner_lora2 !== "None") { 107 | toggleWidget(node, findWidgetByName(node, 'refiner_lora2_model_strength'), true) 108 | toggleWidget(node, findWidgetByName(node, 'refiner_lora2_clip_strength'), true) 109 | } 110 | } 111 | break; 112 | 113 | case 'rescale_after_model': 114 | if (widget.value === false) { 115 | toggleWidget(node, findWidgetByName(node, 'rescale_method')) 116 | toggleWidget(node, findWidgetByName(node, 'rescale')) 117 | toggleWidget(node, findWidgetByName(node, 'percent')) 118 | toggleWidget(node, findWidgetByName(node, 'width')) 119 | toggleWidget(node, findWidgetByName(node, 'height')) 120 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 121 | toggleWidget(node, findWidgetByName(node, 'crop')) 122 | } else { 123 | toggleWidget(node, findWidgetByName(node, 'rescale_method'), true) 124 | toggleWidget(node, findWidgetByName(node, 'rescale'), true) 125 | 126 | let rescale_value = findWidgetByName(node, 'rescale').value 127 | 128 | if (rescale_value === 'by percentage') { 129 | toggleWidget(node, findWidgetByName(node, 'percent'), true) 130 | } else if (rescale_value === 'to Width/Height') { 131 | toggleWidget(node, findWidgetByName(node, 'width'), true) 132 | toggleWidget(node, findWidgetByName(node, 'height'), true) 133 | } else { 134 | toggleWidget(node, findWidgetByName(node, 'longer_side'), true) 135 | } 136 | toggleWidget(node, findWidgetByName(node, 'crop'), true) 137 | } 138 | break; 139 | 140 | case 'rescale': 141 | let rescale_after_model = findWidgetByName(node, 'rescale_after_model')?.value 142 | let hiresfix = findWidgetByName(node, 'upscale_method') || findWidgetByName(node, 'rescale_method') 143 | if (typeof(hiresfix.value) == 'string' && hiresfix.value.includes('hiresFix')) { 144 | hiresfix = true 145 | } else { 146 | hiresfix = false 147 | } 148 | if (widget.value === 'by percentage' && (rescale_after_model || hiresfix)) { 149 | toggleWidget(node, findWidgetByName(node, 'width')) 150 | toggleWidget(node, findWidgetByName(node, 'height')) 151 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 152 | toggleWidget(node, findWidgetByName(node, 'percent'), true) 153 | } else if (widget.value === 'to Width/Height' && (rescale_after_model || hiresfix)) { 154 | toggleWidget(node, findWidgetByName(node, 'width'), true) 155 | toggleWidget(node, findWidgetByName(node, 'height'), true) 156 | toggleWidget(node, findWidgetByName(node, 'percent')) 157 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 158 | } else if (widget.value === 'to longer side - maintain aspect' && (rescale_after_model || hiresfix)) { 159 | toggleWidget(node, findWidgetByName(node, 'width')) 160 | toggleWidget(node, findWidgetByName(node, 'longer_side'), true) 161 | toggleWidget(node, findWidgetByName(node, 'height')) 162 | toggleWidget(node, findWidgetByName(node, 'percent')) 163 | } else if (widget.value === 'None' && (rescale_after_model || hiresfix)) { 164 | toggleWidget(node, findWidgetByName(node, 'width')) 165 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 166 | toggleWidget(node, findWidgetByName(node, 'height')) 167 | toggleWidget(node, findWidgetByName(node, 'percent')) 168 | } else { 169 | toggleWidget(node, findWidgetByName(node, 'width')) 170 | toggleWidget(node, findWidgetByName(node, 'height')) 171 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 172 | toggleWidget(node, findWidgetByName(node, 'percent')) 173 | } 174 | break; 175 | 176 | case 'upscale_method': 177 | if (widget.value === "None") { 178 | toggleWidget(node, findWidgetByName(node, 'factor')) 179 | toggleWidget(node, findWidgetByName(node, 'crop')) 180 | toggleWidget(node, findWidgetByName(node, 'upscale_model_name')) 181 | toggleWidget(node, findWidgetByName(node, 'rescale')) 182 | toggleWidget(node, findWidgetByName(node, 'percent')) 183 | toggleWidget(node, findWidgetByName(node, 'width')) 184 | toggleWidget(node, findWidgetByName(node, 'height')) 185 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 186 | } else { 187 | if (typeof(widget.value) === 'string' && widget.value.includes('[hiresFix]')) { 188 | let rescale = findWidgetByName(node, 'rescale') 189 | toggleWidget(node, rescale, true) 190 | if (rescale?.value === 'by percentage') { 191 | toggleWidget(node, findWidgetByName(node, 'percent'), true) 192 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 193 | toggleWidget(node, findWidgetByName(node, 'width')) 194 | toggleWidget(node, findWidgetByName(node, 'height')) 195 | toggleWidget(node, findWidgetByName(node, 'factor')) 196 | toggleWidget(node, findWidgetByName(node, 'crop')) 197 | } else if (rescale?.value === 'to Width/Height') { 198 | toggleWidget(node, findWidgetByName(node, 'percent')) 199 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 200 | toggleWidget(node, findWidgetByName(node, 'width'), true) 201 | toggleWidget(node, findWidgetByName(node, 'height'), true) 202 | toggleWidget(node, findWidgetByName(node, 'factor')) 203 | toggleWidget(node, findWidgetByName(node, 'crop')) 204 | } else if (rescale?.value === 'to Width/Height') { 205 | toggleWidget(node, findWidgetByName(node, 'percent')) 206 | toggleWidget(node, findWidgetByName(node, 'longer_side'), true) 207 | toggleWidget(node, findWidgetByName(node, 'width')) 208 | toggleWidget(node, findWidgetByName(node, 'height')) 209 | toggleWidget(node, findWidgetByName(node, 'factor')) 210 | toggleWidget(node, findWidgetByName(node, 'crop')) 211 | } else { 212 | toggleWidget(node, findWidgetByName(node, 'percent')) 213 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 214 | toggleWidget(node, findWidgetByName(node, 'width')) 215 | toggleWidget(node, findWidgetByName(node, 'height')) 216 | toggleWidget(node, findWidgetByName(node, 'factor')) 217 | toggleWidget(node, findWidgetByName(node, 'crop')) 218 | } 219 | toggleWidget(node, findWidgetByName(node, 'upscale_model_name'), true) 220 | } else { 221 | toggleWidget(node, findWidgetByName(node, 'upscale_model_name')) 222 | toggleWidget(node, findWidgetByName(node, 'rescale')) 223 | toggleWidget(node, findWidgetByName(node, 'percent')) 224 | toggleWidget(node, findWidgetByName(node, 'width')) 225 | toggleWidget(node, findWidgetByName(node, 'height')) 226 | toggleWidget(node, findWidgetByName(node, 'longer_side')) 227 | toggleWidget(node, findWidgetByName(node, 'factor'), true) 228 | toggleWidget(node, findWidgetByName(node, 'crop'), true) 229 | } 230 | } 231 | break; 232 | 233 | case 'image_output': 234 | if (['Hide', 'Preview'].includes(widget.value)) { 235 | toggleWidget(node, findWidgetByName(node, 'save_prefix')) 236 | toggleWidget(node, findWidgetByName(node, 'output_path')) 237 | toggleWidget(node, findWidgetByName(node, 'embed_workflow')) 238 | toggleWidget(node, findWidgetByName(node, 'number_padding')) 239 | toggleWidget(node, findWidgetByName(node, 'overwrite_existing')) 240 | toggleWidget(node, findWidgetByName(node, 'file_type')) 241 | } else if (['Save', 'Hide/Save', 'Disabled'].includes(widget.value)) { 242 | toggleWidget(node, findWidgetByName(node, 'save_prefix'), true) 243 | toggleWidget(node, findWidgetByName(node, 'output_path'), true) 244 | toggleWidget(node, findWidgetByName(node, 'number_padding'), true) 245 | toggleWidget(node, findWidgetByName(node, 'overwrite_existing'), true) 246 | toggleWidget(node, findWidgetByName(node, 'file_type'), true) 247 | const fileTypeValue = findWidgetByName(node, 'file_type')?.value 248 | if (['png', 'webp'].includes(fileTypeValue)) { 249 | toggleWidget(node, findWidgetByName(node, 'embed_workflow'), true) 250 | } else { 251 | toggleWidget(node, findWidgetByName(node, 'embed_workflow')) 252 | } 253 | } 254 | break; 255 | 256 | case 'text_output': 257 | if (widget.value === "Preview") { 258 | toggleWidget(node, findWidgetByName(node, 'save_prefix')) 259 | toggleWidget(node, findWidgetByName(node, 'output_path')) 260 | toggleWidget(node, findWidgetByName(node, 'number_padding')) 261 | toggleWidget(node, findWidgetByName(node, 'overwrite_existing')) 262 | toggleWidget(node, findWidgetByName(node, 'file_type')) 263 | } else if (widget.value === "Save") { 264 | toggleWidget(node, findWidgetByName(node, 'save_prefix'), true) 265 | toggleWidget(node, findWidgetByName(node, 'output_path'), true) 266 | toggleWidget(node, findWidgetByName(node, 'number_padding'), true) 267 | toggleWidget(node, findWidgetByName(node, 'overwrite_existing'), true) 268 | toggleWidget(node, findWidgetByName(node, 'file_type'), true) 269 | } 270 | 271 | case 'add_noise': 272 | if (widget.value === "disable") { 273 | toggleWidget(node, findWidgetByName(node, 'noise_seed')) 274 | toggleWidget(node, findWidgetByName(node, 'control_after_generate')) 275 | } else { 276 | toggleWidget(node, findWidgetByName(node, 'noise_seed'), true) 277 | toggleWidget(node, findWidgetByName(node, 'control_after_generate'), true) 278 | } 279 | break; 280 | 281 | case 'ckpt_B_name': 282 | if (widget.value === "None") { 283 | toggleWidget(node, findWidgetByName(node, 'config_B_name')) 284 | } else { 285 | toggleWidget(node, findWidgetByName(node, 'config_B_name'), true) 286 | } 287 | break; 288 | 289 | case 'ckpt_C_name': 290 | if (widget.value === "None") { 291 | toggleWidget(node, findWidgetByName(node, 'config_C_name')) 292 | } else { 293 | toggleWidget(node, findWidgetByName(node, 'config_C_name'), true) 294 | } 295 | break; 296 | 297 | case 'save_model': 298 | if (widget.value === "True") { 299 | toggleWidget(node, findWidgetByName(node, 'save_prefix'), true) 300 | 301 | } else { 302 | toggleWidget(node, findWidgetByName(node, 'save_prefix')) 303 | } 304 | break; 305 | 306 | case 'num_loras': 307 | let number_to_show = widget.value + 1 308 | for (let i = 0; i < number_to_show; i++) { 309 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_name'), true) 310 | if (findWidgetByName(node, 'mode').value === "simple") { 311 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_strength'), true) 312 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_model_strength')) 313 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_clip_strength')) 314 | } else { 315 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_strength')) 316 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_model_strength'), true) 317 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_clip_strength'), true) 318 | } 319 | } 320 | for (let i = number_to_show; i < 21; i++) { 321 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_name')) 322 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_strength')) 323 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_model_strength')) 324 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_clip_strength')) 325 | } 326 | updateNodeHeight(node); 327 | break; 328 | 329 | case 'mode': 330 | if (node.constructor.title === "pipeLoraStack") { 331 | let number_to_show2 = findWidgetByName(node, 'num_loras')?.value + 1 332 | for (let i = 0; i < number_to_show2; i++) { 333 | if (widget.value === "simple") { 334 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_strength'), true) 335 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_model_strength')) 336 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_clip_strength')) 337 | } else { 338 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_strength')) 339 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_model_strength'), true) 340 | toggleWidget(node, findWidgetByName(node, 'lora_'+i+'_clip_strength'), true)} 341 | } 342 | updateNodeHeight(node) 343 | break; 344 | } else if (node.constructor.title === "advPlot combo") { 345 | if (widget.value === 'all') { 346 | toggleWidget(node, findWidgetByName(node, 'start_from')) 347 | toggleWidget(node, findWidgetByName(node, 'end_with')) 348 | toggleWidget(node, findWidgetByName(node, 'select')) 349 | toggleWidget(node, findWidgetByName(node, 'selection')) 350 | } else if (widget.value === 'range') { 351 | toggleWidget(node, findWidgetByName(node, 'start_from'), true) 352 | toggleWidget(node, findWidgetByName(node, 'end_with'), true) 353 | toggleWidget(node, findWidgetByName(node, 'select')) 354 | toggleWidget(node, findWidgetByName(node, 'selection')) 355 | } else { 356 | toggleWidget(node, findWidgetByName(node, 'start_from')) 357 | toggleWidget(node, findWidgetByName(node, 'end_with')) 358 | toggleWidget(node, findWidgetByName(node, 'select'), true) 359 | toggleWidget(node, findWidgetByName(node, 'selection'), true) 360 | } 361 | } 362 | 363 | case 'empty_latent_aspect': 364 | if (widget.value !== 'width x height [custom]') { 365 | toggleWidget(node, findWidgetByName(node, 'empty_latent_width')) 366 | toggleWidget(node, findWidgetByName(node, 'empty_latent_height')) 367 | } else { 368 | toggleWidget(node, findWidgetByName(node, 'empty_latent_width'), true) 369 | toggleWidget(node, findWidgetByName(node, 'empty_latent_height'), true) 370 | } 371 | break; 372 | 373 | case 'conditioning_aspect': 374 | if (widget.value !== 'width x height [custom]') { 375 | toggleWidget(node, findWidgetByName(node, 'conditioning_width')) 376 | toggleWidget(node, findWidgetByName(node, 'conditioning_height')) 377 | } else { 378 | toggleWidget(node, findWidgetByName(node, 'conditioning_width'), true) 379 | toggleWidget(node, findWidgetByName(node, 'conditioning_height'), true) 380 | } 381 | break; 382 | 383 | case 'target_aspect': 384 | if (widget.value !== 'width x height [custom]') { 385 | toggleWidget(node, findWidgetByName(node, 'target_width')) 386 | toggleWidget(node, findWidgetByName(node, 'target_height')) 387 | } else { 388 | toggleWidget(node, findWidgetByName(node, 'target_width'), true) 389 | toggleWidget(node, findWidgetByName(node, 'target_height'), true) 390 | } 391 | break; 392 | 393 | case 'toggle': 394 | widget.type = 'toggle' 395 | widget.options = {on: 'Enabled', off: 'Disabled'} 396 | break; 397 | 398 | case 'refiner_steps': 399 | if (widget.value == 0) { 400 | toggleWidget(node, findWidgetByName(node, 'refiner_cfg')) 401 | toggleWidget(node, findWidgetByName(node, 'refiner_denoise')) 402 | } else { 403 | toggleWidget(node, findWidgetByName(node, 'refiner_cfg'), true) 404 | toggleWidget(node, findWidgetByName(node, 'refiner_denoise'), true) 405 | } 406 | break; 407 | 408 | case 'sampler_state': 409 | if (widget.value == 'Hold') { 410 | findWidgetByName(node, 'control_after_generate').value = 'fixed' 411 | } 412 | break; 413 | 414 | case 'print_to_console': 415 | if (widget.value == false) { 416 | toggleWidget(node, findWidgetByName(node, 'console_title')) 417 | toggleWidget(node, findWidgetByName(node, 'console_color')) 418 | } else { 419 | toggleWidget(node, findWidgetByName(node, 'console_title'), true) 420 | toggleWidget(node, findWidgetByName(node, 'console_color'), true) 421 | } 422 | break; 423 | 424 | case 'sampling': 425 | if (widget.value == 'Default') { 426 | toggleWidget(node, findWidgetByName(node, 'zsnr')) 427 | } else { 428 | toggleWidget(node, findWidgetByName(node, 'zsnr'), true) 429 | } 430 | break; 431 | 432 | case 'range_mode': 433 | function setWidgetOptions(widget, options) { 434 | widget.options.step = options.step; 435 | widget.options.round = options.round; 436 | widget.options.precision = options.precision; 437 | } 438 | 439 | if (widget.value.startsWith('step')) { 440 | toggleWidget(node, findWidgetByName(node, 'stop')) 441 | toggleWidget(node, findWidgetByName(node, 'step'), true) 442 | toggleWidget(node, findWidgetByName(node, 'include_stop')) 443 | } else { 444 | toggleWidget(node, findWidgetByName(node, 'stop'), true) 445 | toggleWidget(node, findWidgetByName(node, 'step')) 446 | toggleWidget(node, findWidgetByName(node, 'include_stop'), true) 447 | } 448 | if (widget.value.endsWith('int')) { 449 | const intOptions = { 450 | step: 10, 451 | round: 1, 452 | precision: 0 453 | }; 454 | const start_widget = findWidgetByName(node, 'start') 455 | const stop_widget = findWidgetByName(node, 'stop') 456 | const step_widget = findWidgetByName(node, 'step') 457 | setWidgetOptions(start_widget, intOptions); 458 | setWidgetOptions(stop_widget, intOptions); 459 | setWidgetOptions(step_widget, intOptions); 460 | 461 | } else { 462 | const floatOptions = { 463 | step: 0.1, 464 | round: 0.01, 465 | precision: 2 466 | }; 467 | const start_widget = findWidgetByName(node, 'start') 468 | const stop_widget = findWidgetByName(node, 'stop') 469 | const step_widget = findWidgetByName(node, 'step') 470 | setWidgetOptions(start_widget, floatOptions); 471 | setWidgetOptions(stop_widget, floatOptions); 472 | setWidgetOptions(step_widget, floatOptions); 473 | } 474 | break; 475 | 476 | case 'file_type': 477 | const imageOutputValue = findWidgetByName(node, 'image_output').value 478 | if (['png', 'webp'].includes(widget.value) && ['Save', 'Hide/Save', 'Disabled'].includes(imageOutputValue)) { 479 | toggleWidget(node, findWidgetByName(node, 'embed_workflow'), true) 480 | } else { 481 | toggleWidget(node, findWidgetByName(node, 'embed_workflow')) 482 | } 483 | break; 484 | 485 | case 'replace_mode': 486 | if (widget.value == true) { 487 | toggleWidget(node, findWidgetByName(node, 'search_string'), true) 488 | } else { 489 | toggleWidget(node, findWidgetByName(node, 'search_string')) 490 | } 491 | } 492 | } 493 | 494 | const getSetWidgets = ['rescale_after_model', 'rescale', 'image_output', 495 | 'lora_name', 'lora1_name', 'lora2_name', 'lora3_name', 496 | 'refiner_lora1_name', 'refiner_lora2_name', 'refiner_steps', 'upscale_method', 497 | 'image_output', 'text_output', 'add_noise', 498 | 'ckpt_B_name', 'ckpt_C_name', 'save_model', 'refiner_ckpt_name', 499 | 'num_loras', 'mode', 'toggle', 'empty_latent_aspect', 'conditioning_aspect', 'target_aspect', 'sampler_state', 500 | 'print_to_console', 'sampling', 'range_mode', 'file_type', 'replace_mode'] 501 | const getSetTitles = [ 502 | "hiresfixScale", 503 | "pipeLoader", 504 | "pipeLoader v1 (Legacy)", 505 | "pipeLoaderSDXL", 506 | "pipeLoaderSDXL v1 (Legacy)", 507 | "pipeKSampler", 508 | "pipeKSampler v1 (Legacy)", 509 | "pipeKSamplerAdvanced", 510 | "pipeKSamplerAdvanced v1 (Legacy)", 511 | "pipeKSamplerSDXL", 512 | "pipeKSamplerSDXL v1 (Legacy)", 513 | "imageRemBG", 514 | "imageOutput", 515 | "multiModelMerge", 516 | "pipeLoraStack", 517 | "pipeEncodeConcat", 518 | "tinyKSampler", 519 | "debugInput", 520 | "tinyLoader", 521 | "advPlot range", 522 | "advPlot combo", 523 | "advPlot images", 524 | "advPlot string", 525 | "textOutput", 526 | ]; 527 | 528 | function getSetters(node) { 529 | if (node.widgets) 530 | for (const w of node.widgets) { 531 | if (getSetWidgets.includes(w.name)) { 532 | widgetLogic(node, w); 533 | let widgetValue = w.value; 534 | 535 | // Define getters and setters for widget values 536 | Object.defineProperty(w, 'value', { 537 | get() { 538 | return widgetValue; 539 | }, 540 | set(newVal) { 541 | if (newVal !== widgetValue) { 542 | widgetValue = newVal; 543 | widgetLogic(node, w); 544 | } 545 | } 546 | }); 547 | } 548 | } 549 | } 550 | 551 | app.registerExtension({ 552 | name: "comfy.ttN.dynamicWidgets", 553 | 554 | nodeCreated(node) { 555 | const nodeTitle = node.constructor.title; 556 | if (getSetTitles.includes(nodeTitle)) { 557 | getSetters(node); 558 | } 559 | } 560 | }); -------------------------------------------------------------------------------- /js/ttNembedAC.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { api } from "../../scripts/api.js"; 3 | import { ttN_CreateDropdown, ttN_RemoveDropdown } from "./ttNdropdown.js"; 4 | 5 | // Initialize some global lists and objects. 6 | let autoCompleteDict = {}; // {prefix: [suggestions]} 7 | let autoCompleteHierarchy = {}; 8 | let nsp_keys = ['3d-terms', 'adj-architecture', 'adj-beauty', 'adj-general', 'adj-horror', 'album-cover', 'animals', 'artist', 'artist-botanical', 'artist-surreal', 'aspect-ratio', 'band', 'bird', 'body-fit', 'body-heavy', 'body-light', 'body-poor', 'body-shape', 'body-short', 'body-tall', 'bodyshape', 'camera', 'camera-manu', 'celeb', 'color', 'color-palette', 'comic', 'cosmic-galaxy', 'cosmic-nebula', 'cosmic-star', 'cosmic-terms', 'details', 'dinosaur', 'eyecolor', 'f-stop', 'fantasy-creature', 'fantasy-setting', 'fish', 'flower', 'focal-length', 'foods', 'forest-type', 'fruit', 'games', 'gen-modifier', 'gender', 'gender-ext', 'hair', 'hd', 'identity', 'identity-adult', 'identity-young', 'iso-stop', 'landscape-type', 'movement', 'movie', 'movie-director', 'nationality', 'natl-park', 'neg-weight', 'noun-beauty', 'noun-emote', 'noun-fantasy', 'noun-general', 'noun-horror', 'occupation', 'penciller', 'photo-term', 'pop-culture', 'pop-location', 'portrait-type', 'punk', 'quantity', 'rpg-Item', 'scenario-desc', 'site', 'skin-color', 'style', 'tree', 'trippy', 'water', 'wh-site'] 9 | 10 | function getFileName(path) { 11 | return path.split(/[\/:\\]/).pop(); 12 | } 13 | 14 | function getCurrentWord(widget) { 15 | const formattedInput = widget.inputEl.value.replace(/>\s*/g, '> ').replace(/\s+/g, ' '); 16 | const words = formattedInput.split(' '); 17 | 18 | const adjustedInput = widget.inputEl.value.substring(0, widget.inputEl.selectionStart) 19 | .replace(/>\s*/g, '> ').replace(/\s+/g, ' '); 20 | 21 | const currentWordPosition = adjustedInput.split(' ').length - 1; 22 | 23 | return words[currentWordPosition].toLowerCase(); 24 | } 25 | 26 | function isTriggerWord(word) { 27 | for (let prefix in autoCompleteDict) { 28 | if ((prefix.startsWith(word) && word.length > 1) || word.startsWith(prefix)) return true; 29 | } 30 | return false; 31 | } 32 | 33 | const _generatePrefixes = (str) => { 34 | const prefixes = []; 35 | while (str.length > 1) { 36 | prefixes.push(str); 37 | str = str.substring(0, str.length - 1); 38 | } 39 | return prefixes; 40 | }; 41 | 42 | function _cleanInputWord(word) { 43 | let prefixesToRemove = []; 44 | for (let prefix in autoCompleteDict) { 45 | prefixesToRemove = [...prefixesToRemove, ..._generatePrefixes(prefix)]; 46 | } 47 | let cleanedWord = prefixesToRemove.reduce((acc, prefix) => acc.replace(prefix, ''), word.toLowerCase()); 48 | if (cleanedWord.includes(':')) { 49 | const parts = cleanedWord.split(':'); 50 | cleanedWord = parts[0]; 51 | } 52 | return cleanedWord.replace(/\//g, "\\"); 53 | } 54 | 55 | function getSuggestionsForWord(word) { 56 | let suggestions = []; 57 | for (let prefix in autoCompleteDict) { 58 | if ((prefix.startsWith(word) && word.length > 1) || word.startsWith(prefix)) { 59 | suggestions = autoCompleteDict['fpath_' + prefix]; // Get suggestions from the dictionary 60 | break; 61 | } 62 | } 63 | const cleanedWord = _cleanInputWord(word); 64 | // Filter suggestions based on the cleaned word 65 | return suggestions.filter(suggestion => 66 | suggestion.toLowerCase().includes(cleanedWord) || getFileName(suggestion).toLowerCase().includes(cleanedWord) 67 | ); 68 | } 69 | 70 | 71 | function _convertListToHierarchy(list) { 72 | const hierarchy = {}; 73 | list.forEach(item => { 74 | const parts = item.split(/:\\|\\/); 75 | let node = hierarchy; 76 | parts.forEach((part, idx) => { 77 | node = node[part] = (idx === parts.length - 1) ? null : (node[part] || {}); 78 | }); 79 | }); 80 | return hierarchy; 81 | } 82 | 83 | function _insertSuggestion(widget, suggestion) { 84 | const formattedInput = widget.inputEl.value.replace(/>\s*/g, '> ').replace(/\s+/g, ' '); 85 | const inputSegments = formattedInput.split(' '); 86 | 87 | const adjustedInput = widget.inputEl.value.substring(0, widget.inputEl.selectionStart) 88 | .replace(/>\s*/g, '> ').replace(/\s+/g, ' '); 89 | const currentSegmentIndex = adjustedInput.split(' ').length - 1; 90 | 91 | let matchedPrefix = ''; 92 | let currentSegment = inputSegments[currentSegmentIndex].toLowerCase(); 93 | if (["loras", "refiner_loras"].includes(widget.name) && ['', ' ','<','')) { 109 | oldSuffix = oldSuffix.split('>')[0] + '>'; 110 | } 111 | suffix = oldSuffix ? ':' + oldSuffix : ':1>'; 112 | } 113 | if (matchedPrefix === '__') { 114 | suffix = '__'; 115 | } 116 | 117 | inputSegments[currentSegmentIndex] = matchedPrefix + suggestion + suffix; 118 | return inputSegments.join(' '); 119 | } 120 | 121 | function showSuggestionsDropdown(widget, suggestions) { 122 | const hierarchy = _convertListToHierarchy(suggestions); 123 | ttN_CreateDropdown(widget.inputEl, hierarchy, selected => { 124 | widget.inputEl.value = _insertSuggestion(widget, selected); 125 | }, true); 126 | } 127 | 128 | 129 | function _initializeAutocompleteData(initialList, prefix) { 130 | autoCompleteDict['fpath_' + prefix] = initialList 131 | autoCompleteDict[prefix] = initialList.map(getFileName).map(item => prefix + item); 132 | } 133 | 134 | function _initializeAutocompleteList(initialList, prefix) { 135 | autoCompleteDict['fpath_' + prefix] = initialList 136 | autoCompleteDict[prefix] = initialList.map(item => prefix + item); 137 | } 138 | 139 | function _isRelevantWidget(widget) { 140 | return (["customtext", "ttNhidden"].includes(widget.type) && (widget.dynamicPrompts !== false) || widget.dynamicPrompts) && !_isLorasWidget(widget); 141 | } 142 | 143 | function _isLorasWidget(widget) { 144 | return (["customtext", "ttNhidden"].includes(widget.type) && ["loras", "refiner_loras"].includes(widget.name)); 145 | } 146 | 147 | function findPysssss(lora=false) { 148 | const found = JSON.parse(app.ui.settings.getSettingValue('pysssss.AutoCompleter')) || false; 149 | if (found && lora) { 150 | return JSON.parse(localStorage.getItem("pysssss.AutoCompleter.ShowLoras")) || false; 151 | } 152 | return found; 153 | } 154 | 155 | function _attachInputHandler(widget) { 156 | if (!widget.ttNhandleInput) { 157 | widget.ttNhandleInput = () => { 158 | if (findPysssss()) { 159 | return 160 | } 161 | 162 | let currentWord = getCurrentWord(widget); 163 | if (isTriggerWord(currentWord)) { 164 | const suggestions = getSuggestionsForWord(currentWord); 165 | if (suggestions.length > 0) { 166 | showSuggestionsDropdown(widget, suggestions); 167 | } else { 168 | ttN_RemoveDropdown(); 169 | } 170 | } else { 171 | ttN_RemoveDropdown(); 172 | } 173 | }; 174 | } 175 | ['input', 'mousedown'].forEach(event => { 176 | widget?.inputEl?.removeEventListener(event, widget.ttNhandleInput); 177 | if (findPysssss()) { 178 | return 179 | } 180 | widget?.inputEl?.addEventListener(event, widget.ttNhandleInput); 181 | }); 182 | } 183 | 184 | function _attachLorasHandler(widget) { 185 | if (!widget.ttNhandleLorasInput) { 186 | widget.ttNhandleLorasInput = () => { 187 | if (findPysssss(true)) { 188 | return 189 | } 190 | let currentWord = getCurrentWord(widget); 191 | if (['',' ','<',' 0) { 197 | showSuggestionsDropdown(widget, suggestions); 198 | } else { 199 | ttN_RemoveDropdown(); 200 | } 201 | } else { 202 | ttN_RemoveDropdown(); 203 | } 204 | }; 205 | } 206 | 207 | ['input', 'mouseup'].forEach(event => { 208 | widget?.inputEl?.removeEventListener(event, widget.ttNhandleLorasInput); 209 | if (findPysssss(true)) { 210 | return 211 | } 212 | widget?.inputEl?.addEventListener(event, widget.ttNhandleLorasInput); 213 | }); 214 | 215 | if (!widget.ttNhandleScrollInput) { 216 | widget.ttNhandleScrollInput = (event) => { 217 | event.preventDefault(); 218 | 219 | const step = event.ctrlKey ? 0.1 : 0.01; 220 | 221 | // Determine the scroll direction 222 | const direction = Math.sign(event.deltaY); // Will be -1 for scroll up, 1 for scroll down 223 | 224 | // Get the current selection 225 | const inputEl = widget.inputEl; 226 | let selectionStart = inputEl.selectionStart; 227 | let selectionEnd = inputEl.selectionEnd; 228 | const selected = inputEl.value.substring(selectionStart, selectionEnd); 229 | 230 | if (selected === 'lora' || selected === 'skip') { 231 | const swapWith = selected === 'lora' ? 'skip' : 'lora'; 232 | inputEl.value = inputEl.value.substring(0, selectionStart) + swapWith + inputEl.value.substring(selectionEnd); 233 | inputEl.setSelectionRange(selectionStart, selectionStart + swapWith.length); 234 | return 235 | } 236 | 237 | // Expand the selection to make sure the whole number is selected 238 | while (selectionStart > 0 && /\d|\.|-/.test(inputEl.value.charAt(selectionStart - 1))) { 239 | selectionStart--; 240 | } 241 | while (selectionEnd < inputEl.value.length && /\d|\.|-/.test(inputEl.value.charAt(selectionEnd))) { 242 | selectionEnd++; 243 | } 244 | 245 | const selectedText = inputEl.value.substring(selectionStart, selectionEnd); 246 | 247 | // Check if the selected text is a number 248 | if (!isNaN(selectedText) && selectedText.trim() !== '') { 249 | let trail = selectedText.split('.')[1]?.length; 250 | if (!trail || trail < 2) { 251 | trail = 2; 252 | } 253 | 254 | const currentValue = parseFloat(selectedText); 255 | let modifiedValue = currentValue - direction * step; 256 | 257 | // Format the number to avoid floating point precision issues and then convert back to a float 258 | modifiedValue = parseFloat(modifiedValue.toFixed(trail)); 259 | 260 | // Replace the selected text with the new value, keeping the selection 261 | inputEl.value = inputEl.value.substring(0, selectionStart) + modifiedValue + inputEl.value.substring(selectionEnd); 262 | const newSelectionEnd = selectionStart + modifiedValue.toString().length; 263 | inputEl.setSelectionRange(selectionStart, newSelectionEnd); 264 | } 265 | }; 266 | } 267 | 268 | widget.inputEl.removeEventListener('wheel', widget.ttNhandleScrollInput); 269 | widget.inputEl.addEventListener('wheel', widget.ttNhandleScrollInput); 270 | } 271 | 272 | app.registerExtension({ 273 | name: "comfy.ttN.AutoComplete", 274 | async init() { 275 | const embs = await api.fetchApi("/embeddings") 276 | const loras = await api.fetchApi("/ttN/loras") 277 | 278 | _initializeAutocompleteData(await embs.json(), 'embedding:'); 279 | _initializeAutocompleteData(await loras.json(), 'OK"; 76 | 77 | dialog.close = function() { 78 | if (dialog.parentNode) { 79 | dialog.parentNode.removeChild(dialog); 80 | } 81 | }; 82 | 83 | var inputs = Array.from(dialog.querySelectorAll("input, select")); 84 | 85 | inputs.forEach(input => { 86 | input.addEventListener("keydown", function(e) { 87 | dialog.is_modified = true; 88 | if (e.keyCode == 27) { // ESC 89 | onCancel && onCancel(); 90 | dialog.close(); 91 | } else if (e.keyCode == 13) { // Enter 92 | onOK && onOK(dialog, inputs.map(input => input.value)); 93 | dialog.close(); 94 | } else if (e.keyCode != 13 && e.target.localName != "textarea") { 95 | return; 96 | } 97 | e.preventDefault(); 98 | e.stopPropagation(); 99 | }); 100 | }); 101 | 102 | var graphcanvas = LGraphCanvas.active_canvas; 103 | var canvas = graphcanvas.canvas; 104 | 105 | var rect = canvas.getBoundingClientRect(); 106 | var offsetx = -20; 107 | var offsety = -20; 108 | if (rect) { 109 | offsetx -= rect.left; 110 | offsety -= rect.top; 111 | } 112 | 113 | if (event) { 114 | dialog.style.left = event.clientX + offsetx + "px"; 115 | dialog.style.top = event.clientY + offsety + "px"; 116 | } else { 117 | dialog.style.left = canvas.width * 0.5 + offsetx + "px"; 118 | dialog.style.top = canvas.height * 0.5 + offsety + "px"; 119 | } 120 | 121 | var button = dialog.querySelector("#ok"); 122 | button.addEventListener("click", function() { 123 | onOK && onOK(dialog, inputs.map(input => input.value)); 124 | dialog.close(); 125 | }); 126 | 127 | canvas.parentNode.appendChild(dialog); 128 | 129 | if(inputs) inputs[0].focus(); 130 | 131 | var dialogCloseTimer = null; 132 | dialog.addEventListener("mouseleave", function(e) { 133 | if(LiteGraph.dialog_close_on_mouse_leave) 134 | if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) 135 | dialogCloseTimer = setTimeout(dialog.close, LiteGraph.dialog_close_on_mouse_leave_delay); //dialog.close(); 136 | }); 137 | dialog.addEventListener("mouseenter", function(e) { 138 | if(LiteGraph.dialog_close_on_mouse_leave) 139 | if(dialogCloseTimer) clearTimeout(dialogCloseTimer); 140 | }); 141 | 142 | return dialog; 143 | }; 144 | 145 | LGraphCanvas.prototype.ttNsetNodeDimension = function (node) { 146 | const nodeWidth = node.size[0]; 147 | const nodeHeight = node.size[1]; 148 | 149 | let input_html = ""; 150 | input_html += ""; 151 | 152 | LGraphCanvas.prototype.ttNcreateDialog("Width/Height" + input_html, 153 | function(dialog, values) { 154 | var widthValue = Number(values[0]) ? values[0] : nodeWidth; 155 | var heightValue = Number(values[1]) ? values[1] : nodeHeight; 156 | let sz = node.computeSize(); 157 | node.setSize([Math.max(sz[0], widthValue), Math.max(sz[1], heightValue)]); 158 | if (dialog.parentNode) { 159 | dialog.parentNode.removeChild(dialog); 160 | } 161 | node.setDirtyCanvas(true, true); 162 | }, 163 | null 164 | ); 165 | }; 166 | 167 | LGraphCanvas.prototype.ttNsetSlotTypeColor = function(slot){ 168 | var slotColor = LGraphCanvas.link_type_colors[slot.output.type].toUpperCase(); 169 | var slotType = slot.output.type; 170 | // Check if the color is in the correct format 171 | if (!/^#([0-9A-F]{3}){1,2}$/i.test(slotColor)) { 172 | slotColor = "#FFFFFF"; 173 | } 174 | 175 | // Check if browser supports color input type 176 | var inputType = "color"; 177 | var inputID = " id='colorPicker'"; 178 | var inputElem = document.createElement("input"); 179 | inputElem.setAttribute("type", inputType); 180 | if (inputElem.type !== "color") { 181 | // If it doesn't, fall back to text input 182 | inputType = "text"; 183 | inputID = " "; 184 | } 185 | 186 | let input_html = ""; 187 | input_html += ""; // Add a default button 188 | input_html += ""; // Add a reset button 189 | 190 | var dialog = LGraphCanvas.prototype.ttNcreateDialog("" + slotType + "" + 191 | input_html, 192 | function(dialog, values){ 193 | var hexColor = values[0].toUpperCase(); 194 | 195 | if (!/^#([0-9A-F]{3}){1,2}$/i.test(hexColor)) { 196 | return 197 | } 198 | 199 | if (hexColor === slotColor) { 200 | return 201 | } 202 | 203 | var customLinkColors = JSON.parse(localStorage.getItem('Comfy.Settings.ttN.customLinkColors')) || {}; 204 | if (!customLinkColors[slotType + "_ORIG"]) {customLinkColors[slotType + "_ORIG"] = slotColor}; 205 | customLinkColors[slotType] = hexColor; 206 | localStorage.setItem('Comfy.Settings.ttN.customLinkColors', JSON.stringify(customLinkColors)); 207 | 208 | app.canvas.default_connection_color_byType[slotType] = hexColor; 209 | LGraphCanvas.link_type_colors[slotType] = hexColor; 210 | } 211 | ); 212 | 213 | var resetButton = dialog.querySelector("#reset"); 214 | resetButton.addEventListener("click", function() { 215 | var colorInput = dialog.querySelector("input[type='" + inputType + "']"); 216 | colorInput.value = slotColor; 217 | }); 218 | 219 | var defaultButton = dialog.querySelector("#Default"); 220 | defaultButton.addEventListener("click", function() { 221 | var customLinkColors = JSON.parse(localStorage.getItem('Comfy.Settings.ttN.customLinkColors')) || {}; 222 | if (customLinkColors[slotType+"_ORIG"]) { 223 | app.canvas.default_connection_color_byType[slotType] = customLinkColors[slotType+"_ORIG"]; 224 | LGraphCanvas.link_type_colors[slotType] = customLinkColors[slotType+"_ORIG"]; 225 | 226 | delete customLinkColors[slotType+"_ORIG"]; 227 | delete customLinkColors[slotType]; 228 | } 229 | localStorage.setItem('Comfy.Settings.ttN.customLinkColors', JSON.stringify(customLinkColors)); 230 | dialog.close() 231 | }) 232 | 233 | var colorPicker = dialog.querySelector("input[type='" + inputType + "']"); 234 | colorPicker.addEventListener("focusout", function(e) { 235 | this.focus(); 236 | }); 237 | }; 238 | 239 | LGraphCanvas.prototype.ttNdefaultBGcolor = function(node, defaultBGColor){ 240 | setTimeout(() => { 241 | if (defaultBGColor !== 'default' && !node.color) { 242 | node.addProperty('ttNbgOverride', defaultBGColor); 243 | node.color=defaultBGColor.color; 244 | node.bgcolor=defaultBGColor.bgcolor; 245 | } 246 | 247 | if (node.color && node.properties.ttNbgOverride) { 248 | if (node.properties.ttNbgOverride !== defaultBGColor && node.color === node.properties.ttNbgOverride.color) { 249 | if (defaultBGColor === 'default') { 250 | delete node.properties.ttNbgOverride 251 | delete node.color 252 | delete node.bgcolor 253 | } else { 254 | node.properties.ttNbgOverride = defaultBGColor 255 | node.color=defaultBGColor.color; 256 | node.bgcolor=defaultBGColor.bgcolor; 257 | } 258 | } 259 | 260 | if (node.properties.ttNbgOverride !== defaultBGColor && node.color !== node.properties.ttNbgOverride?.color) { 261 | delete node.properties.ttNbgOverride 262 | } 263 | } 264 | }, 0); 265 | }; 266 | 267 | LGraphCanvas.prototype.ttNfixNodeSize = function(node){ 268 | setTimeout(() => { 269 | node.onResize?.(node.size); 270 | }, 0); 271 | }; 272 | 273 | LGraphCanvas.ttNonShowLinkStyles = function(value, options, e, menu, node) { 274 | new LiteGraph.ContextMenu( 275 | LiteGraph.LINK_RENDER_MODES, 276 | { event: e, callback: inner_clicked, parentMenu: menu, node: node } 277 | ); 278 | 279 | function inner_clicked(v) { 280 | if (!node) { 281 | return; 282 | } 283 | var kV = Object.values(LiteGraph.LINK_RENDER_MODES).indexOf(v); 284 | 285 | localStorage.setItem('Comfy.Settings.Comfy.LinkRenderMode', JSON.stringify(String(kV))); 286 | 287 | app.canvas.links_render_mode = kV; 288 | app.graph.setDirtyCanvas(true); 289 | } 290 | 291 | return false; 292 | }; 293 | 294 | LGraphCanvas.ttNlinkStyleBorder = function(value, options, e, menu, node) { 295 | new LiteGraph.ContextMenu( 296 | [false, true], 297 | { event: e, callback: inner_clicked, parentMenu: menu, node: node } 298 | ); 299 | 300 | function inner_clicked(v) { 301 | if (!node) { 302 | return; 303 | } 304 | 305 | localStorage.setItem('Comfy.Settings.ttN.links_render_border', JSON.stringify(v)); 306 | 307 | app.canvas.render_connections_border = v; 308 | } 309 | 310 | return false; 311 | }; 312 | 313 | LGraphCanvas.ttNlinkStyleShadow = function(value, options, e, menu, node) { 314 | new LiteGraph.ContextMenu( 315 | [false, true], 316 | { event: e, callback: inner_clicked, parentMenu: menu, node: node } 317 | ); 318 | 319 | function inner_clicked(v) { 320 | if (!node) { 321 | return; 322 | } 323 | 324 | localStorage.setItem('Comfy.Settings.ttN.links_render_shadow', JSON.stringify(v)); 325 | 326 | app.canvas.render_connections_shadows = v; 327 | } 328 | 329 | return false; 330 | }; 331 | 332 | LGraphCanvas.ttNsetDefaultBGColor = function(value, options, e, menu, node) { 333 | if (!node) { 334 | throw "no node for color"; 335 | } 336 | 337 | var values = []; 338 | values.push({ 339 | value: null, 340 | content: 341 | "No Color" 342 | }); 343 | 344 | for (var i in LGraphCanvas.node_colors) { 345 | var color = LGraphCanvas.node_colors[i]; 346 | var value = { 347 | value: i, 348 | content: 349 | "" + 354 | i + 355 | "" 356 | }; 357 | values.push(value); 358 | } 359 | new LiteGraph.ContextMenu(values, { 360 | event: e, 361 | callback: inner_clicked, 362 | parentMenu: menu, 363 | node: node 364 | }); 365 | 366 | function inner_clicked(v) { 367 | if (!node) { 368 | return; 369 | } 370 | 371 | var defaultBGColor = v.value ? LGraphCanvas.node_colors[v.value] : 'default'; 372 | 373 | localStorage.setItem('Comfy.Settings.ttN.defaultBGColor', JSON.stringify(defaultBGColor)); 374 | 375 | for (var i in app.graph._nodes) { 376 | LGraphCanvas.prototype.ttNdefaultBGcolor(app.graph._nodes[i], defaultBGColor); 377 | } 378 | 379 | node.setDirtyCanvas(true, true); 380 | } 381 | 382 | return false; 383 | }; 384 | 385 | LGraphCanvas.prototype.ttNupdateRenderSettings = function (app) { 386 | let showLinkBorder = Number(localStorage.getItem('Comfy.Settings.ttN.links_render_border')); 387 | if (showLinkBorder !== undefined) {app.canvas.render_connections_border = showLinkBorder} 388 | 389 | let showLinkShadow = Number(localStorage.getItem('Comfy.Settings.ttN.links_render_shadow')); 390 | if (showLinkShadow !== undefined) {app.canvas.render_connections_shadows = showLinkShadow} 391 | 392 | let showExecOrder = localStorage.getItem('Comfy.Settings.ttN.showExecutionOrder'); 393 | if (showExecOrder === 'true') {app.canvas.render_execution_order = true} 394 | else {app.canvas.render_execution_order = false} 395 | 396 | var customLinkColors = JSON.parse(localStorage.getItem('Comfy.Settings.ttN.customLinkColors')) || {}; 397 | Object.assign(app.canvas.default_connection_color_byType, customLinkColors); 398 | Object.assign(LGraphCanvas.link_type_colors, customLinkColors); 399 | } 400 | }, 401 | 402 | beforeRegisterNodeDef(nodeType, nodeData, app) { 403 | const originalGetSlotMenuOptions = nodeType.prototype.getSlotMenuOptions; 404 | nodeType.prototype.getSlotMenuOptions = (slot) => { 405 | originalGetSlotMenuOptions?.apply(this, slot); 406 | let menu_info = []; 407 | if ( 408 | slot && 409 | slot.output && 410 | slot.output.links && 411 | slot.output.links.length 412 | ) { 413 | menu_info.push({ content: "Disconnect Links", slot: slot }); 414 | } 415 | var _slot = slot.input || slot.output; 416 | if (_slot.removable){ 417 | menu_info.push( 418 | _slot.locked 419 | ? "Cannot remove" 420 | : { content: "Remove Slot", slot: slot } 421 | ); 422 | } 423 | if (!_slot.nameLocked){ 424 | menu_info.push({ content: "Rename Slot", slot: slot }); 425 | } 426 | 427 | menu_info.push({ content: "🌏 Slot Type Color", slot: slot, callback: () => { LGraphCanvas.prototype.ttNsetSlotTypeColor(slot) } }); 428 | menu_info.push({ content: "🌏 Show Link Border", has_submenu: true, slot: slot, callback: LGraphCanvas.ttNlinkStyleBorder }); 429 | menu_info.push({ content: "🌏 Show Link Shadow", has_submenu: true, slot: slot, callback: LGraphCanvas.ttNlinkStyleShadow }); 430 | menu_info.push({ content: "🌏 Link Style", has_submenu: true, slot: slot, callback: LGraphCanvas.ttNonShowLinkStyles }); 431 | 432 | return menu_info; 433 | } 434 | }, 435 | 436 | setup() { 437 | LGraphCanvas.prototype.ttNupdateRenderSettings(app); 438 | }, 439 | nodeCreated(node) { 440 | LGraphCanvas.prototype.ttNfixNodeSize(node); 441 | let defaultBGColor = JSON.parse(localStorage.getItem('Comfy.Settings.ttN.defaultBGColor')); 442 | if (defaultBGColor) {LGraphCanvas.prototype.ttNdefaultBGcolor(node, defaultBGColor)}; 443 | }, 444 | loadedGraphNode(node, app) { 445 | LGraphCanvas.prototype.ttNupdateRenderSettings(app); 446 | 447 | let defaultBGColor = JSON.parse(localStorage.getItem('Comfy.Settings.ttN.defaultBGColor')); 448 | if (defaultBGColor) {LGraphCanvas.prototype.ttNdefaultBGcolor(node, defaultBGColor)}; 449 | }, 450 | }); 451 | 452 | var styleElement = document.createElement("style"); 453 | const cssCode = ` 454 | .ttN-dialog { 455 | top: 10px; 456 | left: 10px; 457 | min-height: 1em; 458 | background-color: var(--comfy-menu-bg); 459 | font-size: 1.2em; 460 | box-shadow: 0 0 7px black !important; 461 | z-index: 10; 462 | display: grid; 463 | border-radius: 7px; 464 | padding: 7px 7px; 465 | position: fixed; 466 | } 467 | .ttN-dialog .name { 468 | display: inline-block; 469 | min-height: 1.5em; 470 | font-size: 14px; 471 | font-family: sans-serif; 472 | color: var(--descrip-text); 473 | padding: 0; 474 | vertical-align: middle; 475 | justify-self: center; 476 | } 477 | .ttN-dialog input, 478 | .ttN-dialog textarea, 479 | .ttN-dialog select { 480 | margin: 3px; 481 | min-width: 60px; 482 | min-height: 1.5em; 483 | background-color: var(--comfy-input-bg); 484 | border: 2px solid; 485 | border-color: var(--border-color); 486 | color: var(--input-text); 487 | border-radius: 14px; 488 | padding-left: 10px; 489 | outline: none; 490 | } 491 | 492 | .ttN-dialog #colorPicker { 493 | margin: 0px; 494 | min-width: 100%; 495 | min-height: 2.5em; 496 | border-radius: 0px; 497 | padding: 0px 2px 0px 2px; 498 | border: unset; 499 | } 500 | 501 | .ttN-dialog textarea { 502 | min-height: 150px; 503 | } 504 | 505 | .ttN-dialog button { 506 | margin-top: 3px; 507 | vertical-align: top; 508 | background-color: #999; 509 | border: 0; 510 | padding: 4px 18px; 511 | border-radius: 20px; 512 | cursor: pointer; 513 | } 514 | 515 | .ttN-dialog button.rounded, 516 | .ttN-dialog input.rounded { 517 | border-radius: 0 12px 12px 0; 518 | } 519 | 520 | .ttN-dialog .helper { 521 | overflow: auto; 522 | max-height: 200px; 523 | } 524 | 525 | .ttN-dialog .help-item { 526 | padding-left: 10px; 527 | } 528 | 529 | .ttN-dialog .help-item:hover, 530 | .ttN-dialog .help-item.selected { 531 | cursor: pointer; 532 | background-color: white; 533 | color: black; 534 | } 535 | ` 536 | styleElement.innerHTML = cssCode 537 | document.head.appendChild(styleElement); 538 | -------------------------------------------------------------------------------- /js/ttNwidgets.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { ComfyWidgets } from "../../scripts/widgets.js"; 3 | 4 | class SeedControl { 5 | constructor(node) { 6 | this.node = node; 7 | 8 | for (const [i, w] of this.node.widgets.entries()) { 9 | if (w.name === "seed" || w.name === "noise_seed") { 10 | this.seedWidget = w; 11 | } 12 | else if (w.name === "control_after_generate" || w.name === "control_before_generate") { 13 | this.controlWidget = w; 14 | } 15 | } 16 | if (!this.seedWidget) { 17 | throw new Error("Something's wrong; expected seed widget"); 18 | } 19 | const randMax = Math.min(1125899906842624, this.seedWidget.options.max); 20 | const randMin = Math.max(0, this.seedWidget.options.min); 21 | const randomRange = (randMax - Math.max(0, randMin)) / (this.seedWidget.options.step / 10); 22 | this.randomSeedButton = this.node.addWidget("button", "🎲 New Fixed Random", null, () => { 23 | this.seedWidget.value = 24 | Math.floor(Math.random() * randomRange) * (this.seedWidget.options.step / 10) + randMin; 25 | this.controlWidget.value = "fixed"; 26 | }, { serialize: false }); 27 | 28 | this.seedWidget.linkedWidgets = [this.randomSeedButton, this.controlWidget]; 29 | } 30 | } 31 | 32 | function addTextDisplay(nodeType) { 33 | const onNodeCreated = nodeType.prototype.onNodeCreated; 34 | nodeType.prototype.onNodeCreated = function () { 35 | const r = onNodeCreated?.apply(this, arguments); 36 | const w = ComfyWidgets["STRING"](this, "display", ["STRING", { multiline: true, placeholder: " " }], app).widget; 37 | w.inputEl.readOnly = true; 38 | w.inputEl.style.opacity = 0.7; 39 | w.inputEl.style.cursor = "auto"; 40 | return r; 41 | }; 42 | 43 | const onExecuted = nodeType.prototype.onExecuted; 44 | nodeType.prototype.onExecuted = function (message) { 45 | onExecuted?.apply(this, arguments); 46 | 47 | for (const widget of this.widgets) { 48 | if (widget.type === "customtext" && widget.name === "display" && widget.inputEl.readOnly === true) { 49 | widget.value = message.text.join(''); 50 | } 51 | } 52 | 53 | this.onResize?.(this.size); 54 | }; 55 | } 56 | 57 | function overwriteSeedControl(nodeType) { 58 | const onNodeCreated = nodeType.prototype.onNodeCreated; 59 | nodeType.prototype.onNodeCreated = function () { 60 | onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; 61 | this.seedControl = new SeedControl(this); 62 | } 63 | } 64 | 65 | const HAS_EXECUTED = Symbol(); 66 | class IndexControl { 67 | constructor(node) { 68 | this.node = node; 69 | this.node.properties = this.node.properties || {}; 70 | for (const [i, w] of this.node.widgets.entries()) { 71 | if (w.name === "index") { 72 | this.indexWidget = w; 73 | } 74 | else if (w.name === "index_control") { 75 | this.controlWidget = w; 76 | } else if (w.name === "text") { 77 | this.textWidget = w; 78 | } 79 | } 80 | 81 | if (!this.indexWidget) { 82 | throw new Error("Something's wrong; expected index widget"); 83 | } 84 | 85 | const applyWidgetControl = () => { 86 | var v = this.controlWidget.value; 87 | 88 | //number 89 | let min = this.indexWidget.options.min; 90 | let max = this.textWidget.value.split("\n").length - 1; 91 | // limit to something that javascript can handle 92 | max = Math.min(1125899906842624, max); 93 | min = Math.max(-1125899906842624, min); 94 | 95 | //adjust values based on valueControl Behaviour 96 | switch (v) { 97 | case "fixed": 98 | break; 99 | case "increment": 100 | this.indexWidget.value += 1; 101 | break; 102 | case "decrement": 103 | this.indexWidget.value -= 1; 104 | break; 105 | case "randomize": 106 | this.indexWidget.value = Math.floor(Math.random() * (max - min + 1)) + min; 107 | default: 108 | break; 109 | } 110 | /*check if values are over or under their respective 111 | * ranges and set them to min or max.*/ 112 | if (this.indexWidget.value < min) this.indexWidget.value = max; 113 | 114 | if (this.indexWidget.value > max) 115 | this.indexWidget.value = min; 116 | this.indexWidget.callback(this.indexWidget.value); 117 | }; 118 | 119 | this.controlWidget.beforeQueued = () => { 120 | // Don't run on first execution 121 | if (this.controlWidget[HAS_EXECUTED]) { 122 | applyWidgetControl(); 123 | } 124 | this.controlWidget[HAS_EXECUTED] = true; 125 | }; 126 | 127 | this.indexWidget.linkedWidgets = [this.controlWidget]; 128 | } 129 | } 130 | 131 | function overwriteIndexControl(nodeType) { 132 | const onNodeCreated = nodeType.prototype.onNodeCreated; 133 | nodeType.prototype.onNodeCreated = function () { 134 | onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; 135 | this.indexControl = new IndexControl(this); 136 | } 137 | } 138 | 139 | app.registerExtension({ 140 | name: "comfy.ttN.widgets", 141 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 142 | if (nodeData.name.startsWith("ttN ") && ["ttN pipeLoader_v2", "ttN pipeKSampler_v2", "ttN pipeKSamplerAdvanced_v2", "ttN pipeLoaderSDXL_v2", "ttN pipeKSamplerSDXL_v2", "ttN KSampler_v2"].includes(nodeData.name)) { 143 | if (nodeData.output_name.includes('seed')) { 144 | overwriteSeedControl(nodeType) 145 | } 146 | } 147 | if (["ttN textDebug", "ttN advPlot range", "ttN advPlot string", "ttN advPlot combo", "ttN debugInput", "ttN textOutput", "ttN advPlot merge"].includes(nodeData.name)) { 148 | addTextDisplay(nodeType) 149 | } 150 | if (nodeData.name.startsWith("ttN textCycle")) { 151 | overwriteIndexControl(nodeType) 152 | } 153 | }, 154 | }); -------------------------------------------------------------------------------- /js/ttNxyPlot.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { ttN_CreateDropdown, ttN_RemoveDropdown } from "./ttNdropdown.js"; 3 | 4 | function generateNumList(dictionary) { 5 | const minimum = dictionary["min"] || 0; 6 | const maximum = dictionary["max"] || 0; 7 | const step = dictionary["step"] || 1; 8 | 9 | if (step === 0) { 10 | return []; 11 | } 12 | 13 | const result = []; 14 | let currentValue = minimum; 15 | 16 | while (currentValue <= maximum) { 17 | if (Number.isInteger(step)) { 18 | result.push(Math.round(currentValue) + '; '); 19 | } else { 20 | let formattedValue = currentValue.toFixed(3); 21 | if(formattedValue == -0.000){ 22 | formattedValue = '0.000'; 23 | } 24 | if (!/\.\d{3}$/.test(formattedValue)) { 25 | formattedValue += "0"; 26 | } 27 | result.push(formattedValue + "; "); 28 | } 29 | currentValue += step; 30 | } 31 | 32 | if (maximum >= 0 && minimum >= 0) { 33 | //low to high 34 | return result; 35 | } 36 | else { 37 | //high to low 38 | return result.reverse(); 39 | } 40 | } 41 | 42 | let plotDict = {}; 43 | let currentOptionsDict = {}; 44 | 45 | function getCurrentOptionLists(node, widget) { 46 | const nodeId = String(node.id); 47 | const widgetName = widget.name; 48 | const widgetValue = widget.value.replace(/^(loader|sampler):\s/, ''); 49 | 50 | if (!currentOptionsDict[nodeId] || !currentOptionsDict[nodeId][widgetName]) { 51 | currentOptionsDict[nodeId] = {...currentOptionsDict[nodeId], [widgetName]: plotDict[widgetValue]}; 52 | } else if (currentOptionsDict[nodeId][widgetName] != plotDict[widgetValue]) { 53 | currentOptionsDict[nodeId][widgetName] = plotDict[widgetValue]; 54 | } 55 | } 56 | 57 | function addGetSetters(node) { 58 | if (node.widgets) 59 | for (const w of node.widgets) { 60 | if (w.name === "x_axis" || 61 | w.name === "y_axis") { 62 | let widgetValue = w.value; 63 | 64 | // Define getters and setters for widget values 65 | Object.defineProperty(w, 'value', { 66 | 67 | get() { 68 | return widgetValue; 69 | }, 70 | set(newVal) { 71 | if (newVal !== widgetValue) { 72 | widgetValue = newVal; 73 | getCurrentOptionLists(node, w); 74 | } 75 | } 76 | }); 77 | } 78 | } 79 | } 80 | 81 | function dropdownCreator(node) { 82 | if (node.widgets) { 83 | const widgets = node.widgets.filter( 84 | (n) => (n.type === "customtext" && n.dynamicPrompts !== false) || n.dynamicPrompts 85 | ); 86 | 87 | for (const w of widgets) { 88 | function replaceOptionSegments(selectedOption, inputSegments, cursorSegmentIndex, optionsList) { 89 | if (selectedOption) { 90 | inputSegments[cursorSegmentIndex] = selectedOption; 91 | } 92 | 93 | return inputSegments.map(segment => verifySegment(segment, optionsList)) 94 | .filter(item => item !== '') 95 | .join(''); 96 | } 97 | 98 | function verifySegment(segment, optionsList) { 99 | segment = cleanSegment(segment); 100 | 101 | if (isInOptionsList(segment, optionsList)) { 102 | return segment + '; '; 103 | } 104 | 105 | let matchedOptions = findMatchedOptions(segment, optionsList); 106 | 107 | if (matchedOptions.length === 1 || matchedOptions.length === 2) { 108 | return matchedOptions[0]; 109 | } 110 | 111 | if (isInOptionsList(formatNumberSegment(segment), optionsList)) { 112 | return formatNumberSegment(segment) + '; '; 113 | } 114 | 115 | return ''; 116 | } 117 | 118 | function cleanSegment(segment) { 119 | return segment.replace(/(\n|;| )/g, ''); 120 | } 121 | 122 | function isInOptionsList(segment, optionsList) { 123 | return optionsList.includes(segment + '; '); 124 | } 125 | 126 | function findMatchedOptions(segment, optionsList) { 127 | return optionsList.filter(option => option.toLowerCase().includes(segment.toLowerCase())); 128 | } 129 | 130 | function formatNumberSegment(segment) { 131 | if (Number(segment)) { 132 | return Number(segment).toFixed(3); 133 | } 134 | 135 | if (['0', '0.', '0.0', '0.00', '00'].includes(segment)) { 136 | return '0.000'; 137 | } 138 | return segment; 139 | } 140 | 141 | 142 | const onInput = function () { 143 | const nodeId = node.id; 144 | const axisWidgetName = w.name[0] + '_axis'; 145 | 146 | let optionsList = currentOptionsDict[nodeId]?.[axisWidgetName] || []; 147 | if (optionsList.length === 0) {return} 148 | 149 | const inputText = w.inputEl.value; 150 | const cursorPosition = w.inputEl.selectionStart; 151 | 152 | let inputSegments = inputText.split('; '); 153 | 154 | const cursorSegmentIndex = inputText.substring(0, cursorPosition).split('; ').length - 1; 155 | const currentSegment = inputSegments[cursorSegmentIndex]; 156 | const currentSegmentLower = currentSegment.replace(/\n/g, '').toLowerCase(); 157 | 158 | const filteredOptionsList = optionsList.filter(option => option.toLowerCase().includes(currentSegmentLower)).map(option => option.replace(/; /g, '')); 159 | 160 | if (filteredOptionsList.length > 0) { 161 | ttN_CreateDropdown(w.inputEl, filteredOptionsList, (selectedOption) => { 162 | const verifiedText = replaceOptionSegments(selectedOption, inputSegments, cursorSegmentIndex, optionsList); 163 | w.inputEl.value = verifiedText; 164 | }); 165 | } 166 | else { 167 | ttN_RemoveDropdown(); 168 | const verifiedText = replaceOptionSegments(null, inputSegments, cursorSegmentIndex, optionsList); 169 | w.inputEl.value = verifiedText; 170 | } 171 | }; 172 | 173 | w.inputEl.removeEventListener('input', onInput); 174 | w.inputEl.addEventListener('input', onInput); 175 | w.inputEl.removeEventListener('mouseup', onInput); 176 | w.inputEl.addEventListener('mouseup', onInput); 177 | } 178 | } 179 | } 180 | 181 | app.registerExtension({ 182 | name: "comfy.ttN.xyPlot", 183 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 184 | if (nodeData.name === "ttN xyPlot") { 185 | plotDict = nodeData.input.hidden.plot_dict[0]; 186 | 187 | for (const key in plotDict) { 188 | const value = plotDict[key]; 189 | if (Array.isArray(value)) { 190 | let updatedValues = []; 191 | for (const v of value) { 192 | updatedValues.push(v + '; '); 193 | } 194 | plotDict[key] = updatedValues; 195 | } else if (typeof(value) === 'object') { 196 | plotDict[key] = generateNumList(value); 197 | } else { 198 | plotDict[key] = value + '; '; 199 | } 200 | } 201 | plotDict["None"] = []; 202 | plotDict["---------------------"] = []; 203 | } 204 | }, 205 | nodeCreated(node) { 206 | if (node.constructor.title === "xyPlot") { 207 | addGetSetters(node); 208 | dropdownCreator(node); 209 | 210 | } 211 | } 212 | }); -------------------------------------------------------------------------------- /js/ttNxyPlotAdv.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { ttN_CreateDropdown, ttN_RemoveDropdown } from "./ttNdropdown.js"; 3 | 4 | const widgets_to_ignore = ['control_after_generate', 'empty_latent_aspect', 'empty_latent_width', 'empty_latent_height', 'batch_size'] 5 | 6 | function getWidgetsOptions(node) { 7 | const widgetsOptions = {} 8 | const widgets = node.widgets 9 | if (!widgets) return 10 | for (const w of widgets) { 11 | if (!w.type || !w.options) continue 12 | const current_value = w.value 13 | if (widgets_to_ignore.includes(w.name)) continue 14 | //console.log(`WIDGET ${w.name}, ${w.type}, ${w.options}`) 15 | if (w.name === 'seed' || (w.name === 'value' && node.constructor.title.toLowerCase() == 'seed')) { 16 | widgetsOptions[w.name] = {'Random Seed': `${w.options.max}/${w.options.min}/${w.options.step}`} 17 | continue 18 | } 19 | if (w.type === 'ttNhidden') { 20 | if (w.options['max']) { 21 | widgetsOptions[w.name] = {[current_value]: null} 22 | continue 23 | } else if (!w.options['values']) { 24 | widgetsOptions[w.name] = {'string': null} 25 | continue 26 | } 27 | } 28 | if (w.type.startsWith('converted') || w.type === 'button') { 29 | continue 30 | } 31 | if (w.type === 'toggle') { 32 | widgetsOptions[w.name] = {'True': null, 'False': null} 33 | continue 34 | } 35 | if (['customtext', 'text', 'string'].includes(w.type)) { 36 | widgetsOptions[w.name] = {'string': null} 37 | continue 38 | } 39 | if (w.type === 'number') { 40 | widgetsOptions[w.name] = {[current_value]: null} 41 | continue 42 | } 43 | let valueDict = {} 44 | if (w.options.values) { 45 | let vals = w.options.values; 46 | 47 | if (typeof w.options.values === 'function') { 48 | vals = w.options.values() 49 | } 50 | 51 | for (const v of vals) { 52 | valueDict[v] = null 53 | } 54 | } 55 | widgetsOptions[w.name] = valueDict 56 | } 57 | 58 | //console.log('WIDGETS OPTIONS', widgetsOptions) 59 | if (Object.keys(widgetsOptions).length === 0) { 60 | return null 61 | } 62 | return widgetsOptions; 63 | } 64 | 65 | function _addInputIDs(node, inputIDs, IDsToCheck) { 66 | if (node.inputs) { 67 | for (const input of node.inputs) { 68 | if (input.link) { 69 | let originID = node.graph.links[input.link].origin_id 70 | inputIDs.push(originID); 71 | if (!IDsToCheck.includes(originID)) { 72 | IDsToCheck.push(originID); 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | function _recursiveGetInputIDs(node) { 80 | const inputIDs = []; 81 | const IDsToCheck = [node.id]; 82 | 83 | while (IDsToCheck.length > 0) { 84 | const currentID = IDsToCheck.pop(); 85 | const currentNode = node.graph._nodes_by_id[currentID]; 86 | if (currentNode.type === "ttN advanced xyPlot") { 87 | continue 88 | } 89 | _addInputIDs(currentNode, inputIDs, IDsToCheck); 90 | } 91 | 92 | return inputIDs; 93 | } 94 | 95 | function getNodesWidgetsDict(xyNode, plotLines=false) { 96 | const nodeWidgets = {}; 97 | if (plotLines) { 98 | nodeWidgets['Add Plot Line'] = {'Only Values Label': null, 'Title and Values Label': null, 'ID, Title and Values Label': null}; 99 | } 100 | 101 | const xyNodeLinks = xyNode.outputs[0]?.links 102 | if (!xyNodeLinks || xyNodeLinks.length == 0) { 103 | nodeWidgets['Connect to advanced xyPlot for options'] = null 104 | return nodeWidgets 105 | } 106 | 107 | const plotNodeLink = xyNodeLinks[0] 108 | const plotNodeID = xyNode.graph.links[plotNodeLink].target_id 109 | const plotNodeTitle = xyNode.graph._nodes_by_id[plotNodeID].constructor.title 110 | const plotNode = app.graph._nodes_by_id[plotNodeID] 111 | 112 | const options = getWidgetsOptions(plotNode) 113 | if (options) { 114 | nodeWidgets[`[${plotNodeID}] - ${plotNodeTitle}`] = options 115 | } 116 | 117 | const inputIDS = _recursiveGetInputIDs(plotNode) 118 | for (const iID of inputIDS) { 119 | const iNode = app.graph._nodes_by_id[iID]; 120 | const iNodeTitle = iNode.constructor.title 121 | if (iNodeTitle === 'advanced xyPlot') { 122 | continue 123 | } 124 | const options = getWidgetsOptions(iNode) 125 | if (!options) continue 126 | nodeWidgets[`[${iID}] - ${iNodeTitle}`] = getWidgetsOptions(iNode) 127 | } 128 | return nodeWidgets 129 | } 130 | 131 | function dropdownCreator(node) { 132 | if (node.widgets) { 133 | const widgets = node.widgets.filter( 134 | (n) => (n.type === "customtext") 135 | ); 136 | 137 | for (const w of widgets) { 138 | 139 | const onInput = function () { 140 | const nodeWidgets = getNodesWidgetsDict(node, true); 141 | const inputText = w.inputEl.value; 142 | const cursorPosition = w.inputEl.selectionStart; 143 | 144 | let lines = inputText.split('\n'); 145 | if (lines.length === 0) return; 146 | 147 | let cursorLineIndex = 0; 148 | let lineStartPosition = 0; 149 | 150 | for (let i = 0; i < lines.length; i++) { 151 | const lineEndPosition = lineStartPosition + lines[i].length; 152 | if (cursorPosition <= lineEndPosition) { 153 | cursorLineIndex = i; 154 | break; 155 | } 156 | lineStartPosition = lineEndPosition + 1; 157 | } 158 | 159 | ttN_CreateDropdown(w.inputEl, nodeWidgets, (selectedOption, fullpath) => { 160 | const data = fullpath.split('###'); 161 | const parts = data[0].split('/'); 162 | let output; 163 | if (parts[0] === 'Add Plot Line') { 164 | const labelType = parts[1]; 165 | let label; 166 | switch (labelType) { 167 | case 'Only Values Label': 168 | label = 'v_label'; 169 | break; 170 | case 'Title and Values Label': 171 | label = 'tv_label'; 172 | break; 173 | case 'ID, Title and Values Label': 174 | label = 'idtv_label'; 175 | break; 176 | } 177 | 178 | let lastOpeningAxisBracket = -1; 179 | let lastClosingAxisBracket = -1; 180 | 181 | let bracketCount = 0; 182 | for (let i = 0; i < inputText.length; i++) { 183 | if (inputText[i] === '[') { 184 | bracketCount++; 185 | } else if (inputText[i] === ']') { 186 | bracketCount--; 187 | } else if (inputText[i] === '<' && bracketCount === 0) { 188 | lastOpeningAxisBracket = i; 189 | } else if (inputText[i] === '>' && bracketCount === 0) { 190 | lastClosingAxisBracket = i; 191 | } 192 | } 193 | 194 | const lastAxisBracket = inputText.substring(lastOpeningAxisBracket + 1, lastClosingAxisBracket).split(':')[0]; 195 | let nextAxisBracketNumber; 196 | 197 | if (inputText.trim() === '') { 198 | w.inputEl.value = `<1:${label}>\n`; 199 | return 200 | } 201 | 202 | if (lastAxisBracket) { 203 | const lastAxisBracketNumber = Number(lastAxisBracket); 204 | if (!isNaN(lastAxisBracketNumber)) { 205 | nextAxisBracketNumber = lastAxisBracketNumber + 1; 206 | output = `<${nextAxisBracketNumber}:${label}>\n`; 207 | if (inputText[inputText.length - 1] === '\n') { 208 | w.inputEl.value = `${inputText}${output}` 209 | } else { 210 | w.inputEl.value = `${inputText}\n${output}` 211 | } 212 | return 213 | } 214 | } 215 | return 216 | } 217 | if (parts[0] === 'Connect to advanced xyPlot for options') { 218 | return 219 | } 220 | 221 | if (selectedOption === 'Random Seed') { 222 | const [max, min, step] = data[1].split('/'); 223 | 224 | const randMax = Math.min(1125899906842624, Number(max)); 225 | const randMin = Math.max(0, Number(min)); 226 | const randomRange = (randMax - Math.max(0, randMin)) / (Number(step) / 10); 227 | selectedOption = Math.floor(Math.random() * randomRange) * (Number(step) / 10) + randMin; 228 | } 229 | const nodeID = data[0].split(' - ')[0].replace('[', '').replace(']', ''); 230 | 231 | output = `[${nodeID}:${parts[1]}='${selectedOption}']`; 232 | 233 | if (inputText.trim() === '') { 234 | output = `<1:v_label>\n` + output; 235 | } 236 | 237 | if (lines[cursorLineIndex].trim() === '') { 238 | lines[cursorLineIndex] = output; 239 | } else { 240 | lines.splice(cursorLineIndex + 1, 0, output); 241 | } 242 | 243 | w.inputEl.value = lines.join('\n'); 244 | 245 | }, true); 246 | }; 247 | 248 | w.inputEl.removeEventListener('input', onInput); 249 | w.inputEl.addEventListener('input', onInput); 250 | w.inputEl.removeEventListener('mouseup', onInput); 251 | w.inputEl.addEventListener('mouseup', onInput); 252 | } 253 | } 254 | } 255 | 256 | function findUpstreamXYPlot(targetID) { 257 | const currentNode = app.graph._nodes_by_id[targetID]; 258 | if (!currentNode) { 259 | return 260 | } 261 | if (currentNode.constructor.title === 'advanced xyPlot') { 262 | return currentNode; 263 | } else { 264 | if (!currentNode.outputs) { 265 | return 266 | } 267 | for (const output of currentNode.outputs) { 268 | if (output.links?.length > 0) { 269 | for (const link of output.links) { 270 | const xyPlotNode = findUpstreamXYPlot(app.graph.links[link].target_id) 271 | if (xyPlotNode) { 272 | return xyPlotNode 273 | } 274 | } 275 | } 276 | } 277 | } 278 | } 279 | 280 | function setPlotNodeOptions(currentNode, targetID=null) { 281 | if (!targetID) { 282 | for (const output of currentNode.outputs) { 283 | if (output.links?.length > 0) { 284 | for (const link of output.links) { 285 | targetID = app.graph.links[link].target_id 286 | } 287 | } 288 | } 289 | } 290 | const xyPlotNode = findUpstreamXYPlot(targetID) 291 | if (!xyPlotNode) { 292 | return 293 | } 294 | const widgets_dict = getNodesWidgetsDict(xyPlotNode) 295 | const currentWidget = currentNode.widgets.find(w => w.name === 'node'); 296 | if (currentWidget) { 297 | currentWidget.options.values = Object.keys(widgets_dict) 298 | } 299 | } 300 | 301 | function setPlotWidgetOptions(currentNode, searchType) { 302 | const { value } = currentNode.widgets.find(w => w.name === 'node'); 303 | const nodeIdRegex = /\[(\d+)\]/; 304 | const match = value.match(nodeIdRegex); 305 | const nodeId = match ? parseInt(match[1], 10) : null; 306 | if (!nodeId) return; 307 | 308 | const optionNode = app.graph._nodes_by_id[nodeId]; 309 | if (!optionNode || !optionNode.widgets) return; 310 | 311 | const widgetsList = Object.values(optionNode.widgets) 312 | .filter( 313 | function(w) { 314 | if (searchType) { 315 | return searchType.includes(w.type) 316 | } 317 | } 318 | ) 319 | .map((w) => w.name); 320 | 321 | if (widgetsList) { 322 | for (const w of currentNode.widgets) { 323 | if (w.name === 'widget') { 324 | w.options.values = widgetsList 325 | } 326 | } 327 | } 328 | 329 | 330 | const widgetWidget = currentNode.widgets.find(w => w.name === 'widget'); 331 | const widgetWidgetValue = widgetWidget.value; 332 | 333 | if (searchType.includes('number')) { 334 | const int_widgets = [ 335 | 'seed', 336 | 'clip_skip', 337 | 'steps', 338 | 'start_at_step', 339 | 'end_at_step', 340 | 'empty_latent_width', 341 | 'empty_latent_height', 342 | 'noise_seed', 343 | ] 344 | const float_widgets = [ 345 | 'cfg', 346 | 'denoise', 347 | 'strength_model', 348 | 'strength_clip', 349 | 'strength', 350 | 'scale_by', 351 | 'lora_strength' 352 | ] 353 | 354 | const rangeModeWidget = currentNode.widgets.find(w => w.name === 'range_mode'); 355 | const rangeModeWidgetValue = rangeModeWidget.value; 356 | 357 | if (int_widgets.includes(widgetWidgetValue)) { 358 | rangeModeWidget.options.values = ['step_int', 'num_steps_int'] 359 | if (rangeModeWidgetValue === 'num_steps_float') { 360 | rangeModeWidget.value = 'num_steps_int' 361 | } 362 | if (rangeModeWidgetValue === 'step_float') { 363 | rangeModeWidget.value = 'step_int' 364 | } 365 | } else if (float_widgets.includes(widgetWidgetValue)) { 366 | rangeModeWidget.options.values = ['step_float', 'num_steps_float'] 367 | rangeModeWidget.value.replace('int', 'float') 368 | if (rangeModeWidgetValue === 'num_steps_int') { 369 | rangeModeWidget.value = 'num_steps_float' 370 | } 371 | if (rangeModeWidgetValue === 'step_int') { 372 | rangeModeWidget.value = 'step_float' 373 | } 374 | } else { 375 | rangeModeWidget.options.values = ['step_int', 'num_steps_int', 'step_float', 'num_steps_float'] 376 | } 377 | } 378 | if (searchType.includes('combo')) { 379 | const optionsWidget = optionNode.widgets.find(w => w.name === widgetWidgetValue) 380 | if (optionsWidget) { 381 | const values = optionsWidget.options.values 382 | currentNode.widgets.find(w => w.name === 'start_from').options.values = values 383 | currentNode.widgets.find(w => w.name === 'end_with').options.values = values 384 | currentNode.widgets.find(w => w.name === 'select').options.values = values 385 | } 386 | } 387 | } 388 | 389 | const getSetWidgets = [ 390 | "node", 391 | "widget", 392 | "start_from", 393 | "end_with", 394 | ] 395 | 396 | function getSetters(node, searchType) { 397 | if (node.widgets) { 398 | const gswidgets = node.widgets.filter(function(widget) { 399 | return getSetWidgets.includes(widget.name); 400 | }); 401 | for (const w of gswidgets) { 402 | setPlotWidgetOptions(node, searchType); 403 | let widgetValue = w.value; 404 | 405 | // Define getters and setters for widget values 406 | Object.defineProperty(w, 'value', { 407 | get() { 408 | return widgetValue; 409 | }, 410 | set(newVal) { 411 | if (newVal !== widgetValue) { 412 | widgetValue = newVal; 413 | setPlotWidgetOptions(node, searchType); 414 | } 415 | } 416 | }); 417 | } 418 | 419 | const selectWidget = node.widgets.find(w => w.name === 'select') 420 | if (selectWidget) { 421 | let widgetValue = selectWidget.value; 422 | let selectedWidget = node.widgets.find(w => w.name === 'selection'); 423 | 424 | Object.defineProperty(selectWidget, 'value', { 425 | get() { 426 | return widgetValue; 427 | }, 428 | set(newVal) { 429 | if (newVal !== widgetValue) { 430 | widgetValue = newVal; 431 | if (selectedWidget.inputEl.value.trim() === '') { 432 | selectedWidget.inputEl.value = newVal; 433 | } else { 434 | selectedWidget.inputEl.value += "\n" + newVal; 435 | } 436 | } 437 | } 438 | }) 439 | } 440 | } 441 | let mouseOver = node.mouseOver; 442 | Object.defineProperty(node, 'mouseOver', { 443 | get() { 444 | return mouseOver; 445 | }, 446 | set(newVal) { 447 | if (newVal !== mouseOver) { 448 | mouseOver = newVal; 449 | if (mouseOver) { 450 | setPlotWidgetOptions(node, searchType); 451 | setPlotNodeOptions(node); 452 | } 453 | } 454 | } 455 | }) 456 | 457 | } 458 | 459 | 460 | app.registerExtension({ 461 | name: "comfy.ttN.xyPlotAdv", 462 | beforeRegisterNodeDef(nodeType, nodeData, app) { 463 | 464 | /*if (nodeData.name === "ttN advPlot range") { 465 | const origOnConnectionsChange = nodeType.prototype.onConnectionsChange; 466 | nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, link_info, _ioSlot) { 467 | const r = origOnConnectionsChange ? origOnConnectionsChange.apply(this, arguments) : undefined; 468 | if (link_info && (slotIndex == 0 || slotIndex == 1)) { 469 | const originID = link_info?.origin_id 470 | const targetID = link_info?.target_id 471 | 472 | const currentNode = app.graph._nodes_by_id[originID]; 473 | 474 | setPlotNodeOptions(currentNode, targetID) 475 | } 476 | return r; 477 | }; 478 | }*/ 479 | }, 480 | nodeCreated(node) { 481 | const node_title = node.constructor.title; 482 | 483 | if (node_title === "advanced xyPlot") { 484 | dropdownCreator(node); 485 | } 486 | if (node_title === "advPlot range") { 487 | getSetters(node, ['number',]); 488 | } 489 | if (node_title === "advPlot string") { 490 | getSetters(node, ['text', 'customtext']); 491 | } 492 | if (node_title === "advPlot combo") { 493 | getSetters(node, ['combo',]); 494 | } 495 | }, 496 | }); -------------------------------------------------------------------------------- /js/utils.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { api } from "../../scripts/api.js"; 3 | import { ComfyDialog, $el } from "../../scripts/ui.js"; 4 | 5 | 6 | export function rebootAPI() { 7 | if (confirm("Are you sure you'd like to reboot the server?")) { 8 | try { 9 | api.fetchApi("/ttN/reboot"); 10 | } 11 | catch(exception) { 12 | console.log("Failed to reboot: " + exception); 13 | } 14 | return true; 15 | } 16 | 17 | return false; 18 | } 19 | 20 | export function wait(ms = 16, value) { 21 | return new Promise((resolve) => { 22 | setTimeout(() => { 23 | resolve(value); 24 | }, ms); 25 | }); 26 | } 27 | 28 | 29 | 30 | 31 | 32 | 33 | const CONVERTED_TYPE = "converted-widget"; 34 | const GET_CONFIG = Symbol(); 35 | 36 | export function getConfig(widgetName, node) { 37 | const { nodeData } = node.constructor; 38 | return nodeData?.input?.required[widgetName] ?? nodeData?.input?.optional?.[widgetName]; 39 | } 40 | 41 | export function hideWidget(node, widget, suffix = "") { 42 | widget.origType = widget.type; 43 | widget.origComputeSize = widget.computeSize; 44 | widget.origSerializeValue = widget.serializeValue; 45 | widget.computeSize = () => [0, -4]; // -4 is due to the gap litegraph adds between widgets automatically 46 | widget.type = CONVERTED_TYPE + suffix; 47 | widget.serializeValue = () => { 48 | // Prevent serializing the widget if we have no input linked 49 | if (!node.inputs) { 50 | return undefined; 51 | } 52 | let node_input = node.inputs.find((i) => i.widget?.name === widget.name); 53 | 54 | if (!node_input || !node_input.link) { 55 | return undefined; 56 | } 57 | return widget.origSerializeValue ? widget.origSerializeValue() : widget.value; 58 | }; 59 | 60 | // Hide any linked widgets, e.g. seed+seedControl 61 | if (widget.linkedWidgets) { 62 | for (const w of widget.linkedWidgets) { 63 | hideWidget(node, w, ":" + widget.name); 64 | } 65 | } 66 | } 67 | 68 | export function getWidgetType(config) { 69 | // Special handling for COMBO so we restrict links based on the entries 70 | let type = config[0]; 71 | if (type instanceof Array) { 72 | type = "COMBO"; 73 | } 74 | return { type }; 75 | } 76 | 77 | export function convertToInput(node, widget, config) { 78 | hideWidget(node, widget); 79 | 80 | const { type } = getWidgetType(config); 81 | 82 | // Add input and store widget config for creating on primitive node 83 | const sz = node.size; 84 | node.addInput(widget.name, type, { 85 | widget: { name: widget.name, [GET_CONFIG]: () => config }, 86 | }); 87 | 88 | for (const widget of node.widgets) { 89 | widget.last_y += LiteGraph.NODE_SLOT_HEIGHT; 90 | } 91 | 92 | // Restore original size but grow if needed 93 | node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])]); 94 | } 95 | 96 | export function tinyterraReloadNode(node) { 97 | // Retrieves original values or uses current ones as fallback. Options for creating a new node. 98 | const { title: nodeTitle, color: nodeColor, bgcolor: bgColor } = node.properties.origVals || node; 99 | const options = { 100 | size: [...node.size], 101 | color: nodeColor, 102 | bgcolor: bgColor, 103 | pos: [...node.pos] 104 | }; 105 | 106 | // Store a reference to the old node before it gets replaced. 107 | const oldNode = node 108 | 109 | // Track connections to re-establish later. 110 | const inputConnections = [], outputConnections = []; 111 | if (node.inputs) { 112 | for (const input of node.inputs ?? []) { 113 | if (input.link) { 114 | const input_name = input.name 115 | const input_slot = node.findInputSlot(input_name) 116 | const input_node = node.getInputNode(input_slot) 117 | const input_link = node.getInputLink(input_slot) 118 | 119 | inputConnections.push([input_link.origin_slot, input_node, input_name]) 120 | } 121 | } 122 | } 123 | if (node.outputs) { 124 | for (const output of node.outputs) { 125 | if (output.links) { 126 | const output_name = output.name 127 | 128 | for (const linkID of output.links) { 129 | const output_link = graph.links[linkID] 130 | const output_node = graph._nodes_by_id[output_link.target_id] 131 | outputConnections.push([output_name, output_node, output_link.target_slot]) 132 | } 133 | } 134 | } 135 | } 136 | // Remove old node and create a new one. 137 | app.graph.remove(node) 138 | const newNode = app.graph.add(LiteGraph.createNode(node.constructor.type, nodeTitle, options)); 139 | if (newNode?.constructor?.hasOwnProperty('ttNnodeVersion')) { 140 | newNode.properties.ttNnodeVersion = newNode.constructor.ttNnodeVersion; 141 | } 142 | 143 | // A function to handle reconnection of links to the new node. 144 | function handleLinks() { 145 | for (let ow of oldNode.widgets) { 146 | if (ow.type === CONVERTED_TYPE) { 147 | const config = getConfig(ow.name, oldNode) 148 | const WidgetToConvert = newNode.widgets.find((nw) => nw.name === ow.name); 149 | if (WidgetToConvert && !newNode?.inputs?.find((i) => i.name === ow.name)) { 150 | convertToInput(newNode, WidgetToConvert, config); 151 | } 152 | } 153 | } 154 | 155 | // replace input and output links 156 | for (let input of inputConnections) { 157 | const [output_slot, output_node, input_name] = input; 158 | output_node.connect(output_slot, newNode.id, input_name) 159 | } 160 | for (let output of outputConnections) { 161 | const [output_name, input_node, input_slot] = output; 162 | newNode.connect(output_name, input_node, input_slot) 163 | } 164 | } 165 | 166 | // fix widget values 167 | let values = oldNode.widgets_values; 168 | if (!values) { 169 | console.log('NO VALUES') 170 | newNode.widgets.forEach((newWidget, index) => { 171 | let pass = false 172 | while ((index < oldNode.widgets.length) && !pass) { 173 | const oldWidget = oldNode.widgets[index]; 174 | if (newWidget.type === oldWidget.type) { 175 | newWidget.value = oldWidget.value; 176 | pass = true 177 | } 178 | index++; 179 | } 180 | }); 181 | } 182 | else { 183 | let isValid = false 184 | const isIterateForwards = values.length <= newNode.widgets.length; 185 | let valueIndex = isIterateForwards ? 0 : values.length - 1; 186 | 187 | const parseWidgetValue = (value, widget) => { 188 | if (['', null].includes(value) && (widget.type === "button" || widget.type === "converted-widget")) { 189 | return { value, isValid: true }; 190 | } 191 | if (typeof value === "boolean" && widget.options?.on && widget.options?.off) { 192 | return { value, isValid: true }; 193 | } 194 | if (widget.options?.values?.includes(value)) { 195 | return { value, isValid: true }; 196 | } 197 | if (widget.inputEl) { 198 | if (typeof value === "string" || value === widget.value) { 199 | return { value, isValid: true }; 200 | } 201 | } 202 | if (!isNaN(value)) { 203 | value = parseFloat(value); 204 | if (widget.options?.min <= value && value <= widget.options?.max) { 205 | return { value, isValid: true }; 206 | } 207 | } 208 | return { value: widget.value, isValid: false }; 209 | }; 210 | 211 | function updateValue(widgetIndex) { 212 | const oldWidget = oldNode.widgets[widgetIndex]; 213 | let newWidget = newNode.widgets[widgetIndex]; 214 | let newValueIndex = valueIndex 215 | 216 | if (newWidget.name === oldWidget.name && (newWidget.type === oldWidget.type || oldWidget.type === 'ttNhidden' || newWidget.type === 'ttNhidden')) { 217 | 218 | while ((isIterateForwards ? newValueIndex < values.length : newValueIndex >= 0) && !isValid) { 219 | let { value, isValid } = parseWidgetValue(values[newValueIndex], newWidget); 220 | if (isValid && value !== NaN) { 221 | newWidget.value = value; 222 | break; 223 | } 224 | newValueIndex += isIterateForwards ? 1 : -1; 225 | } 226 | 227 | if (isIterateForwards) { 228 | if (newValueIndex === valueIndex) { 229 | valueIndex++; 230 | } 231 | if (newValueIndex === valueIndex + 1) { 232 | valueIndex++; 233 | valueIndex++; 234 | } 235 | } else { 236 | if (newValueIndex === valueIndex) { 237 | valueIndex--; 238 | } 239 | if (newValueIndex === valueIndex - 1) { 240 | valueIndex--; 241 | valueIndex--; 242 | } 243 | } 244 | //console.log('\n') 245 | } 246 | }; 247 | if (isIterateForwards) { 248 | for (let widgetIndex = 0; widgetIndex < newNode.widgets.length; widgetIndex++) { 249 | updateValue(widgetIndex); 250 | } 251 | } else { 252 | for (let widgetIndex = newNode.widgets.length - 1; widgetIndex >= 0; widgetIndex--) { 253 | updateValue(widgetIndex); 254 | } 255 | } 256 | } 257 | handleLinks(); 258 | 259 | newNode.setSize(options.size) 260 | newNode.onResize([0,0]); 261 | }; -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui_tinyterranodes" 3 | description = "Customizable xyPlot, various pipe nodes, fullscreen image viewer based on node history, dynamic widgets, interface customization, and more." 4 | version = "2.0.7" 5 | license = { file = "LICENSE" } 6 | 7 | [project.urls] 8 | Repository = "https://github.com/TinyTerra/ComfyUI_tinyterraNodes" 9 | 10 | [tool.comfy] 11 | PublisherId = "tinyterra" 12 | DisplayName = "tinyterraNodes" 13 | Icon = "images/icon.jpg" 14 | Models = [] 15 | -------------------------------------------------------------------------------- /ttNdev.py: -------------------------------------------------------------------------------- 1 | # in_dev - likely broken 2 | class ttN_compareInput: 3 | @classmethod 4 | def INPUT_TYPES(s): 5 | return {"required": {"console_title": ("STRING", {"default": "ttN INPUT COMPARE"}),}, 6 | "optional": {"debug": ("", {"default": None}), 7 | "debug2": ("", {"default": None}),} 8 | } 9 | 10 | RETURN_TYPES = tuple() 11 | RETURN_NAMES = tuple() 12 | FUNCTION = "debug" 13 | CATEGORY = "🌏 tinyterra/dev" 14 | OUTPUT_NODE = True 15 | 16 | def debug(_, **kwargs): 17 | 18 | values = [] 19 | for key, value in kwargs.items(): 20 | if key == "console_title": 21 | print(value) 22 | else: 23 | print(f"{key}: {value}") 24 | values.append(value) 25 | 26 | return tuple() 27 | 28 | NODE_CLASS_MAPPINGS = { 29 | "ttN compareInput": ttN_compareInput, 30 | } 31 | 32 | NODE_DISPLAY_NAME_MAPPINGS = { 33 | "ttN compareInput": "compareInput", 34 | } -------------------------------------------------------------------------------- /ttNpy/adv_encode.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import numpy as np 3 | import itertools 4 | from math import gcd 5 | 6 | from comfy import model_management 7 | from comfy.sdxl_clip import SDXLClipModel, SDXLRefinerClipModel, SDXLClipG, StableCascadeClipModel 8 | try: 9 | from comfy.text_encoders.sd3_clip import SD3ClipModel, T5XXLModel 10 | except ImportError: 11 | from comfy.sd3_clip import SD3ClipModel, T5XXLModel 12 | 13 | try: 14 | from comfy.text_encoders.flux import FluxClipModel 15 | except: 16 | FluxClipModel = None 17 | pass 18 | 19 | def _grouper(n, iterable): 20 | it = iter(iterable) 21 | while True: 22 | chunk = list(itertools.islice(it, n)) 23 | if not chunk: 24 | return 25 | yield chunk 26 | 27 | def _norm_mag(w, n): 28 | d = w - 1 29 | return 1 + np.sign(d) * np.sqrt(np.abs(d) ** 2 / n) 30 | # return np.sign(w) * np.sqrt(np.abs(w)**2 / n) 31 | 32 | def divide_length(word_ids, weights): 33 | sums = dict(zip(*np.unique(word_ids, return_counts=True))) 34 | sums[0] = 1 35 | weights = [[_norm_mag(w, sums[id]) if id != 0 else 1.0 36 | for w, id in zip(x, y)] for x, y in zip(weights, word_ids)] 37 | return weights 38 | 39 | def shift_mean_weight(word_ids, weights): 40 | delta = 1 - np.mean([w for x, y in zip(weights, word_ids) for w, id in zip(x, y) if id != 0]) 41 | weights = [[w if id == 0 else w + delta 42 | for w, id in zip(x, y)] for x, y in zip(weights, word_ids)] 43 | return weights 44 | 45 | def scale_to_norm(weights, word_ids, w_max): 46 | top = np.max(weights) 47 | w_max = min(top, w_max) 48 | weights = [[w_max if id == 0 else (w / top) * w_max 49 | for w, id in zip(x, y)] for x, y in zip(weights, word_ids)] 50 | return weights 51 | 52 | def from_zero(weights, base_emb): 53 | weight_tensor = torch.tensor(weights, dtype=base_emb.dtype, device=base_emb.device) 54 | weight_tensor = weight_tensor.reshape(1, -1, 1).expand(base_emb.shape) 55 | return base_emb * weight_tensor 56 | 57 | def mask_word_id(tokens, word_ids, target_id, mask_token): 58 | new_tokens = [[mask_token if wid == target_id else t 59 | for t, wid in zip(x, y)] for x, y in zip(tokens, word_ids)] 60 | mask = np.array(word_ids) == target_id 61 | return (new_tokens, mask) 62 | 63 | def batched_clip_encode(tokens, length, encode_func, num_chunks): 64 | embs = [] 65 | for e in _grouper(32, tokens): 66 | enc, pooled = encode_func(e) 67 | try: 68 | enc = enc.reshape((len(e), length, -1)) 69 | except: 70 | raise Exception("Down_Weight and Comfy++ weight interpretations are not currently supported with this model.") 71 | embs.append(enc) 72 | embs = torch.cat(embs) 73 | embs = embs.reshape((len(tokens) // num_chunks, length * num_chunks, -1)) 74 | return embs 75 | 76 | def from_masked(tokens, weights, word_ids, base_emb, length, encode_func, m_token=266): 77 | pooled_base = base_emb[0, length - 1:length, :] 78 | wids, inds = np.unique(np.array(word_ids).reshape(-1), return_index=True) 79 | weight_dict = dict((id, w) 80 | for id, w in zip(wids, np.array(weights).reshape(-1)[inds]) 81 | if w != 1.0) 82 | 83 | if len(weight_dict) == 0: 84 | return torch.zeros_like(base_emb), base_emb[0, length - 1:length, :] 85 | 86 | weight_tensor = torch.tensor(weights, dtype=base_emb.dtype, device=base_emb.device) 87 | weight_tensor = weight_tensor.reshape(1, -1, 1).expand(base_emb.shape) 88 | 89 | # m_token = (clip.tokenizer.end_token, 1.0) if clip.tokenizer.pad_with_end else (0,1.0) 90 | # TODO: find most suitable masking token here 91 | m_token = (m_token, 1.0) 92 | 93 | ws = [] 94 | masked_tokens = [] 95 | masks = [] 96 | 97 | # create prompts 98 | for id, w in weight_dict.items(): 99 | masked, m = mask_word_id(tokens, word_ids, id, m_token) 100 | masked_tokens.extend(masked) 101 | 102 | m = torch.tensor(m, dtype=base_emb.dtype, device=base_emb.device) 103 | m = m.reshape(1, -1, 1).expand(base_emb.shape) 104 | masks.append(m) 105 | 106 | ws.append(w) 107 | 108 | # batch process prompts 109 | embs = batched_clip_encode(masked_tokens, length, encode_func, len(tokens)) 110 | masks = torch.cat(masks) 111 | 112 | embs = (base_emb.expand(embs.shape) - embs) 113 | pooled = embs[0, length - 1:length, :] 114 | 115 | embs *= masks 116 | embs = embs.sum(axis=0, keepdim=True) 117 | 118 | pooled_start = pooled_base.expand(len(ws), -1) 119 | ws = torch.tensor(ws).reshape(-1, 1).expand(pooled_start.shape) 120 | pooled = (pooled - pooled_start) * (ws - 1) 121 | pooled = pooled.mean(axis=0, keepdim=True) 122 | 123 | return ((weight_tensor - 1) * embs), pooled_base + pooled 124 | 125 | def mask_inds(tokens, inds, mask_token): 126 | clip_len = len(tokens[0]) 127 | inds_set = set(inds) 128 | new_tokens = [[mask_token if i * clip_len + j in inds_set else t 129 | for j, t in enumerate(x)] for i, x in enumerate(tokens)] 130 | return new_tokens 131 | 132 | def down_weight(tokens, weights, word_ids, base_emb, length, encode_func, m_token=266): 133 | w, w_inv = np.unique(weights, return_inverse=True) 134 | 135 | if np.sum(w < 1) == 0: 136 | return base_emb, tokens, base_emb[0, length - 1:length, :] 137 | # m_token = (clip.tokenizer.end_token, 1.0) if clip.tokenizer.pad_with_end else (0,1.0) 138 | # using the comma token as a masking token seems to work better than aos tokens for SD 1.x 139 | m_token = (m_token, 1.0) 140 | 141 | masked_tokens = [] 142 | 143 | masked_current = tokens 144 | for i in range(len(w)): 145 | if w[i] >= 1: 146 | continue 147 | masked_current = mask_inds(masked_current, np.where(w_inv == i)[0], m_token) 148 | masked_tokens.extend(masked_current) 149 | 150 | embs = batched_clip_encode(masked_tokens, length, encode_func, len(tokens)) 151 | embs = torch.cat([base_emb, embs]) 152 | w = w[w <= 1.0] 153 | w_mix = np.diff([0] + w.tolist()) 154 | w_mix = torch.tensor(w_mix, dtype=embs.dtype, device=embs.device).reshape((-1, 1, 1)) 155 | 156 | weighted_emb = (w_mix * embs).sum(axis=0, keepdim=True) 157 | return weighted_emb, masked_current, weighted_emb[0, length - 1:length, :] 158 | 159 | def scale_emb_to_mag(base_emb, weighted_emb): 160 | norm_base = torch.linalg.norm(base_emb) 161 | norm_weighted = torch.linalg.norm(weighted_emb) 162 | embeddings_final = (norm_base / norm_weighted) * weighted_emb 163 | return embeddings_final 164 | 165 | def recover_dist(base_emb, weighted_emb): 166 | fixed_std = (base_emb.std() / weighted_emb.std()) * (weighted_emb - weighted_emb.mean()) 167 | embeddings_final = fixed_std + (base_emb.mean() - fixed_std.mean()) 168 | return embeddings_final 169 | 170 | def A1111_renorm(base_emb, weighted_emb): 171 | embeddings_final = (base_emb.mean() / weighted_emb.mean()) * weighted_emb 172 | return embeddings_final 173 | 174 | def advanced_encode_from_tokens(tokenized, token_normalization, weight_interpretation, encode_func, m_token=266, 175 | length=77, w_max=1.0, return_pooled=False, apply_to_pooled=False): 176 | tokens = [[t for t, _, _ in x] for x in tokenized] 177 | weights = [[w for _, w, _ in x] for x in tokenized] 178 | word_ids = [[wid for _, _, wid in x] for x in tokenized] 179 | 180 | # weight normalization 181 | # ==================== 182 | 183 | # distribute down/up weights over word lengths 184 | if token_normalization.startswith("length"): 185 | weights = divide_length(word_ids, weights) 186 | 187 | # make mean of word tokens 1 188 | if token_normalization.endswith("mean"): 189 | weights = shift_mean_weight(word_ids, weights) 190 | 191 | # weight interpretation 192 | # ===================== 193 | pooled = None 194 | 195 | if weight_interpretation == "comfy": 196 | weighted_tokens = [[(t, w) for t, w in zip(x, y)] for x, y in zip(tokens, weights)] 197 | weighted_emb, pooled_base = encode_func(weighted_tokens) 198 | pooled = pooled_base 199 | else: 200 | unweighted_tokens = [[(t, 1.0) for t, _, _ in x] for x in tokenized] 201 | base_emb, pooled_base = encode_func(unweighted_tokens) 202 | 203 | if weight_interpretation == "A1111": 204 | weighted_emb = from_zero(weights, base_emb) 205 | weighted_emb = A1111_renorm(base_emb, weighted_emb) 206 | pooled = pooled_base 207 | 208 | if weight_interpretation == "compel": 209 | pos_tokens = [[(t, w) if w >= 1.0 else (t, 1.0) for t, w in zip(x, y)] for x, y in zip(tokens, weights)] 210 | weighted_emb, _ = encode_func(pos_tokens) 211 | weighted_emb, _, pooled = down_weight(pos_tokens, weights, word_ids, weighted_emb, length, encode_func) 212 | 213 | if weight_interpretation == "comfy++": 214 | weighted_emb, tokens_down, _ = down_weight(unweighted_tokens, weights, word_ids, base_emb, length, encode_func) 215 | weights = [[w if w > 1.0 else 1.0 for w in x] for x in weights] 216 | # unweighted_tokens = [[(t,1.0) for t, _, _ in x] for x in tokens_down] 217 | embs, pooled = from_masked(unweighted_tokens, weights, word_ids, base_emb, length, encode_func) 218 | weighted_emb += embs 219 | 220 | if weight_interpretation == "down_weight": 221 | weights = scale_to_norm(weights, word_ids, w_max) 222 | weighted_emb, _, pooled = down_weight(unweighted_tokens, weights, word_ids, base_emb, length, encode_func) 223 | 224 | if return_pooled: 225 | if apply_to_pooled: 226 | return weighted_emb, pooled 227 | else: 228 | return weighted_emb, pooled_base 229 | return weighted_emb, None 230 | 231 | def encode_token_weights_g(model, token_weight_pairs): 232 | return model.clip_g.encode_token_weights(token_weight_pairs) 233 | 234 | def encode_token_weights_l(model, token_weight_pairs): 235 | return model.clip_l.encode_token_weights(token_weight_pairs) 236 | 237 | def encode_token_weights_t5(model, token_weight_pairs): 238 | return model.t5xxl.encode_token_weights(token_weight_pairs) 239 | 240 | def encode_token_weights(model, token_weight_pairs, encode_func): 241 | if model.layer_idx is not None: 242 | model.cond_stage_model.set_clip_options({"layer": model.layer_idx}) 243 | 244 | model_management.load_model_gpu(model.patcher) 245 | return encode_func(model.cond_stage_model, token_weight_pairs) 246 | 247 | def prepareXL(embs_l, embs_g, pooled, clip_balance): 248 | l_w = 1 - max(0, clip_balance - .5) * 2 249 | g_w = 1 - max(0, .5 - clip_balance) * 2 250 | if embs_l is not None: 251 | return torch.cat([embs_l * l_w, embs_g * g_w], dim=-1), pooled 252 | else: 253 | return embs_g, pooled 254 | 255 | def prepareSD3(out, pooled, clip_balance): 256 | lg_w = 1 - max(0, clip_balance - .5) * 2 257 | t5_w = 1 - max(0, .5 - clip_balance) * 2 258 | if out.shape[0] > 1: 259 | return torch.cat([out[0] * lg_w, out[1] * t5_w], dim=-1), pooled 260 | else: 261 | return out, pooled 262 | 263 | def advanced_encode(clip, text, token_normalization, weight_interpretation, w_max=1.0, clip_balance=.5, apply_to_pooled=True): 264 | tokenized = clip.tokenize(text, return_word_ids=True) 265 | 266 | if SD3ClipModel and isinstance(clip.cond_stage_model, SD3ClipModel): 267 | lg_out = None 268 | pooled = None 269 | out = None 270 | 271 | if len(tokenized['l']) > 0 or len(tokenized['g']) > 0: 272 | if 'l' in tokenized: 273 | lg_out, l_pooled = advanced_encode_from_tokens(tokenized['l'], 274 | token_normalization, 275 | weight_interpretation, 276 | lambda x: encode_token_weights(clip, x, encode_token_weights_l), 277 | w_max=w_max, return_pooled=True,) 278 | else: 279 | l_pooled = torch.zeros((1, 768), device=model_management.intermediate_device()) 280 | 281 | if 'g' in tokenized: 282 | g_out, g_pooled = advanced_encode_from_tokens(tokenized['g'], 283 | token_normalization, 284 | weight_interpretation, 285 | lambda x: encode_token_weights(clip, x, encode_token_weights_g), 286 | w_max=w_max, return_pooled=True) 287 | if lg_out is not None: 288 | lg_out = torch.cat([lg_out, g_out], dim=-1) 289 | else: 290 | lg_out = torch.nn.functional.pad(g_out, (768, 0)) 291 | else: 292 | g_out = None 293 | g_pooled = torch.zeros((1, 1280), device=model_management.intermediate_device()) 294 | 295 | if lg_out is not None: 296 | lg_out = torch.nn.functional.pad(lg_out, (0, 4096 - lg_out.shape[-1])) 297 | out = lg_out 298 | pooled = torch.cat((l_pooled, g_pooled), dim=-1) 299 | 300 | # t5xxl 301 | if 't5xxl' in tokenized and clip.cond_stage_model.t5xxl is not None: 302 | t5_out, t5_pooled = advanced_encode_from_tokens(tokenized['t5xxl'], 303 | token_normalization, 304 | weight_interpretation, 305 | lambda x: encode_token_weights(clip, x, encode_token_weights_t5), 306 | w_max=w_max, return_pooled=True) 307 | if lg_out is not None: 308 | out = torch.cat([lg_out, t5_out], dim=-2) 309 | else: 310 | out = t5_out 311 | 312 | if out is None: 313 | out = torch.zeros((1, 77, 4096), device=model_management.intermediate_device()) 314 | 315 | if pooled is None: 316 | pooled = torch.zeros((1, 768 + 1280), device=model_management.intermediate_device()) 317 | 318 | return prepareSD3(out, pooled, clip_balance) 319 | 320 | elif FluxClipModel and isinstance(clip.cond_stage_model, FluxClipModel): 321 | if 't5xxl' in tokenized and clip.cond_stage_model.t5xxl is not None: 322 | t5_out, t5_pooled = advanced_encode_from_tokens(tokenized['t5xxl'], 323 | token_normalization, 324 | weight_interpretation, 325 | lambda x: encode_token_weights(clip, x, encode_token_weights_t5), 326 | w_max=w_max, return_pooled=True,) 327 | 328 | if len(tokenized['l']) > 0: 329 | if 'l' in tokenized: 330 | l_out, l_pooled = advanced_encode_from_tokens(tokenized['l'], 331 | token_normalization, 332 | weight_interpretation, 333 | lambda x: encode_token_weights(clip, x, encode_token_weights_l), 334 | w_max=w_max, return_pooled=True,) 335 | else: 336 | l_pooled = torch.zeros((1, 768), device=model_management.intermediate_device()) 337 | 338 | return t5_out, l_pooled 339 | 340 | elif isinstance(clip.cond_stage_model, (SDXLClipModel, SDXLRefinerClipModel, SDXLClipG)): 341 | embs_l = None 342 | embs_g = None 343 | pooled = None 344 | if 'l' in tokenized and isinstance(clip.cond_stage_model, SDXLClipModel): 345 | embs_l, _ = advanced_encode_from_tokens(tokenized['l'], 346 | token_normalization, 347 | weight_interpretation, 348 | lambda x: encode_token_weights(clip, x, encode_token_weights_l), 349 | w_max=w_max, 350 | return_pooled=False) 351 | if 'g' in tokenized: 352 | embs_g, pooled = advanced_encode_from_tokens(tokenized['g'], 353 | token_normalization, 354 | weight_interpretation, 355 | lambda x: encode_token_weights(clip, x, encode_token_weights_g), 356 | w_max=w_max, 357 | return_pooled=True, 358 | apply_to_pooled=apply_to_pooled) 359 | return prepareXL(embs_l, embs_g, pooled, clip_balance) 360 | 361 | elif isinstance(clip.cond_stage_model, StableCascadeClipModel): 362 | return advanced_encode_from_tokens( 363 | tokenized['g'], 364 | token_normalization, 365 | weight_interpretation, 366 | lambda x: encode_token_weights(clip, x, encode_token_weights_g), 367 | w_max=w_max, 368 | return_pooled=True, 369 | apply_to_pooled=apply_to_pooled 370 | ) 371 | else: 372 | return advanced_encode_from_tokens(tokenized['l'], 373 | token_normalization, 374 | weight_interpretation, 375 | lambda x: (clip.encode_from_tokens({'l': x}), None), 376 | w_max=w_max) 377 | 378 | def advanced_encode_XL(clip, text1, text2, token_normalization, weight_interpretation, w_max=1.0, clip_balance=.5, apply_to_pooled=True): 379 | tokenized1 = clip.tokenize(text1, return_word_ids=True) 380 | tokenized2 = clip.tokenize(text2, return_word_ids=True) 381 | 382 | embs_l, _ = advanced_encode_from_tokens(tokenized1['l'], 383 | token_normalization, 384 | weight_interpretation, 385 | lambda x: encode_token_weights(clip, x, encode_token_weights_l), 386 | w_max=w_max, 387 | return_pooled=False) 388 | 389 | embs_g, pooled = advanced_encode_from_tokens(tokenized2['g'], 390 | token_normalization, 391 | weight_interpretation, 392 | lambda x: encode_token_weights(clip, x, encode_token_weights_g), 393 | w_max=w_max, 394 | return_pooled=True, 395 | apply_to_pooled=apply_to_pooled) 396 | 397 | gcd_num = gcd(embs_l.shape[1], embs_g.shape[1]) 398 | repeat_l = int((embs_g.shape[1] / gcd_num) * embs_l.shape[1]) 399 | repeat_g = int((embs_l.shape[1] / gcd_num) * embs_g.shape[1]) 400 | 401 | return prepareXL(embs_l.expand((-1,repeat_l,-1)), embs_g.expand((-1,repeat_g,-1)), pooled, clip_balance) -------------------------------------------------------------------------------- /ttNpy/ttNexecutor.py: -------------------------------------------------------------------------------- 1 | import nodes 2 | import torch 3 | import comfy.model_management 4 | import copy 5 | import logging 6 | import sys 7 | import traceback 8 | from execution import full_type_name 9 | 10 | def get_input_data(inputs, class_def, unique_id, outputs={}, prompt={}, extra_data={}): 11 | valid_inputs = class_def.INPUT_TYPES() 12 | input_data_all = {} 13 | for x in inputs: 14 | input_data = inputs[x] 15 | if isinstance(input_data, list): 16 | input_unique_id = input_data[0] 17 | output_index = input_data[1] 18 | if input_unique_id not in outputs: 19 | input_data_all[x] = (None,) 20 | continue 21 | obj = outputs[input_unique_id][output_index] 22 | input_data_all[x] = obj 23 | else: 24 | if ("required" in valid_inputs and x in valid_inputs["required"]) or ("optional" in valid_inputs and x in valid_inputs["optional"]): 25 | input_data_all[x] = [input_data] 26 | 27 | if "hidden" in valid_inputs: 28 | h = valid_inputs["hidden"] 29 | for x in h: 30 | if h[x] == "PROMPT": 31 | input_data_all[x] = [prompt] 32 | if h[x] == "EXTRA_PNGINFO": 33 | input_data_all[x] = [extra_data.get('extra_pnginfo', None)] 34 | if h[x] == "UNIQUE_ID": 35 | input_data_all[x] = [unique_id] 36 | return input_data_all 37 | 38 | def get_output_data(obj, input_data_all): 39 | results = [] 40 | uis = [] 41 | return_values = map_node_over_list(obj, input_data_all, obj.FUNCTION, allow_interrupt=True) 42 | 43 | for r in return_values: 44 | if isinstance(r, dict): 45 | if 'ui' in r: 46 | uis.append(r['ui']) 47 | if 'result' in r: 48 | results.append(r['result']) 49 | else: 50 | results.append(r) 51 | 52 | output = [] 53 | if len(results) > 0: 54 | # check which outputs need concatenating 55 | output_is_list = [False] * len(results[0]) 56 | if hasattr(obj, "OUTPUT_IS_LIST"): 57 | output_is_list = obj.OUTPUT_IS_LIST 58 | 59 | # merge node execution results 60 | for i, is_list in zip(range(len(results[0])), output_is_list): 61 | if is_list: 62 | output.append([x for o in results for x in o[i]]) 63 | else: 64 | output.append([o[i] for o in results]) 65 | 66 | ui = dict() 67 | if len(uis) > 0: 68 | ui = {k: [y for x in uis for y in x[k]] for k in uis[0].keys()} 69 | return output, ui 70 | 71 | class ttN_advanced_XYPlot: 72 | version = '1.1.0' 73 | plotPlaceholder = "_PLOT\nExample:\n\n\n[node_ID:widget_Name='value']\n\n\n[node_ID:widget_Name='value2']\n[node_ID:widget2_Name='value']\n[node_ID2:widget_Name='value']\n\netc..." 74 | 75 | def get_plot_points(plot_data, unique_id): 76 | if plot_data is None or plot_data.strip() == '': 77 | return None 78 | else: 79 | try: 80 | axis_dict = {} 81 | lines = plot_data.split('<') 82 | new_lines = [] 83 | temp_line = '' 84 | 85 | for line in lines: 86 | if line.startswith('lora'): 87 | temp_line += '<' + line 88 | new_lines[-1] = temp_line 89 | else: 90 | new_lines.append(line) 91 | temp_line = line 92 | 93 | for line in new_lines: 94 | if line: 95 | values_label = [] 96 | line = line.split('>', 1) 97 | num, label = line[0].split(':', 1) 98 | axis_dict[num] = {"label": label} 99 | for point in line[1].split('['): 100 | if point.strip() != '': 101 | node_id = point.split(':', 1)[0] 102 | axis_dict[num][node_id] = {} 103 | input_name = point.split(':', 1)[1].split('=')[0] 104 | value = point.split("'")[1].split("'")[0] 105 | values_label.append((value, input_name, node_id)) 106 | 107 | axis_dict[num][node_id][input_name] = value 108 | 109 | if label in ['v_label', 'tv_label', 'idtv_label']: 110 | new_label = [] 111 | for value, input_name, node_id in values_label: 112 | if label == 'v_label': 113 | new_label.append(value) 114 | elif label == 'tv_label': 115 | new_label.append(f'{input_name}: {value}') 116 | elif label == 'idtv_label': 117 | new_label.append(f'[{node_id}] {input_name}: {value}') 118 | axis_dict[num]['label'] = ', '.join(new_label) 119 | 120 | except ValueError: 121 | ttNl('Invalid Plot - defaulting to None...').t(f'advanced_XYPlot[{unique_id}]').warn().p() 122 | return None 123 | return axis_dict 124 | 125 | def __init__(self): 126 | pass 127 | 128 | @classmethod 129 | def INPUT_TYPES(s): 130 | return { 131 | "required": { 132 | "grid_spacing": ("INT",{"min": 0, "max": 500, "step": 5, "default": 0,}), 133 | "save_individuals": ("BOOLEAN", {"default": False}), 134 | "flip_xy": ("BOOLEAN", {"default": False}), 135 | 136 | "x_plot": ("STRING",{"default": '', "multiline": True, "placeholder": 'X' + ttN_advanced_XYPlot.plotPlaceholder, "pysssss.autocomplete": False}), 137 | "y_plot": ("STRING",{"default": '', "multiline": True, "placeholder": 'Y' + ttN_advanced_XYPlot.plotPlaceholder, "pysssss.autocomplete": False}), 138 | }, 139 | "hidden": { 140 | "prompt": ("PROMPT",), 141 | "extra_pnginfo": ("EXTRA_PNGINFO",), 142 | "my_unique_id": ("MY_UNIQUE_ID",), 143 | "ttNnodeVersion": ttN_advanced_XYPlot.version, 144 | }, 145 | } 146 | 147 | RETURN_TYPES = ("ADV_XYPLOT", ) 148 | RETURN_NAMES = ("adv_xyPlot", ) 149 | FUNCTION = "plot" 150 | 151 | CATEGORY = "🌏 tinyterra/xyPlot" 152 | 153 | def plot(self, grid_spacing, save_individuals, flip_xy, x_plot=None, y_plot=None, prompt=None, extra_pnginfo=None, my_unique_id=None): 154 | x_plot = ttN_advanced_XYPlot.get_plot_points(x_plot, my_unique_id) 155 | y_plot = ttN_advanced_XYPlot.get_plot_points(y_plot, my_unique_id) 156 | 157 | if x_plot == {}: 158 | x_plot = None 159 | if y_plot == {}: 160 | y_plot = None 161 | 162 | if flip_xy == "True": 163 | x_plot, y_plot = y_plot, x_plot 164 | 165 | xy_plot = {"x_plot": x_plot, 166 | "y_plot": y_plot, 167 | "grid_spacing": grid_spacing, 168 | "save_individuals": save_individuals,} 169 | 170 | return (xy_plot, ) 171 | 172 | class ttN_Plotting(ttN_advanced_XYPlot): 173 | def plot(self, **args): 174 | xy_plot = None 175 | return (xy_plot, ) 176 | 177 | 178 | def map_node_over_list(obj, input_data_all, func, allow_interrupt=False): 179 | # check if node wants the lists 180 | input_is_list = False 181 | if hasattr(obj, "INPUT_IS_LIST"): 182 | input_is_list = obj.INPUT_IS_LIST 183 | 184 | if len(input_data_all) == 0: 185 | max_len_input = 0 186 | else: 187 | max_len_input = max([len(x) for x in input_data_all.values()]) 188 | 189 | # get a slice of inputs, repeat last input when list isn't long enough 190 | def slice_dict(d, i): 191 | d_new = dict() 192 | for k,v in d.items(): 193 | d_new[k] = v[i if len(v) > i else -1] 194 | return d_new 195 | 196 | results = [] 197 | if input_is_list: 198 | if allow_interrupt: 199 | nodes.before_node_execution() 200 | results.append(getattr(obj, func)(**input_data_all)) 201 | elif max_len_input == 0: 202 | if allow_interrupt: 203 | nodes.before_node_execution() 204 | results.append(getattr(obj, func)()) 205 | else: 206 | for i in range(max_len_input): 207 | if allow_interrupt: 208 | nodes.before_node_execution() 209 | results.append(getattr(obj, func)(**slice_dict(input_data_all, i))) 210 | return results 211 | 212 | def format_value(x): 213 | if x is None: 214 | return None 215 | elif isinstance(x, (int, float, bool, str)): 216 | return x 217 | else: 218 | return str(x) 219 | 220 | def recursive_execute(prompt, outputs, current_item, extra_data, executed, prompt_id, outputs_ui, object_storage): 221 | unique_id = current_item 222 | inputs = prompt[unique_id]['inputs'] 223 | class_type = prompt[unique_id]['class_type'] 224 | if class_type == "ttN advanced xyPlot": 225 | class_def = ttN_Plotting #Fake class to avoid recursive execute of xy_plot node 226 | else: 227 | class_def = nodes.NODE_CLASS_MAPPINGS[class_type] 228 | 229 | if unique_id in outputs: 230 | print('returning already executed', unique_id) 231 | return (True, None, None) 232 | 233 | for x in inputs: 234 | input_data = inputs[x] 235 | 236 | if isinstance(input_data, list): 237 | input_unique_id = input_data[0] 238 | output_index = input_data[1] 239 | if input_unique_id not in outputs: 240 | result = recursive_execute(prompt, outputs, input_unique_id, extra_data, executed, prompt_id, outputs_ui, object_storage) 241 | if result[0] is not True: 242 | # Another node failed further upstream 243 | return result 244 | 245 | input_data_all = None 246 | try: 247 | input_data_all = get_input_data(inputs, class_def, unique_id, outputs, prompt, extra_data) 248 | 249 | obj = object_storage.get((unique_id, class_type), None) 250 | if obj is None: 251 | obj = class_def() 252 | object_storage[(unique_id, class_type)] = obj 253 | 254 | output_data, output_ui = get_output_data(obj, input_data_all) 255 | outputs[unique_id] = output_data 256 | if len(output_ui) > 0: 257 | outputs_ui[unique_id] = output_ui 258 | 259 | except comfy.model_management.InterruptProcessingException as iex: 260 | logging.info("Processing interrupted") 261 | 262 | # skip formatting inputs/outputs 263 | error_details = { 264 | "node_id": unique_id, 265 | } 266 | 267 | return (False, error_details, iex) 268 | except Exception as ex: 269 | typ, _, tb = sys.exc_info() 270 | exception_type = full_type_name(typ) 271 | input_data_formatted = {} 272 | if input_data_all is not None: 273 | input_data_formatted = {} 274 | for name, inputs in input_data_all.items(): 275 | input_data_formatted[name] = [format_value(x) for x in inputs] 276 | 277 | output_data_formatted = {} 278 | for node_id, node_outputs in outputs.items(): 279 | output_data_formatted[node_id] = [[format_value(x) for x in l] for l in node_outputs] 280 | 281 | logging.error(f"!!! Exception during xyPlot processing!!! {ex}") 282 | logging.error(traceback.format_exc()) 283 | 284 | error_details = { 285 | "node_id": unique_id, 286 | "exception_message": str(ex), 287 | "exception_type": exception_type, 288 | "traceback": traceback.format_tb(tb), 289 | "current_inputs": input_data_formatted, 290 | "current_outputs": output_data_formatted 291 | } 292 | return (False, error_details, ex) 293 | 294 | executed.add(unique_id) 295 | 296 | return (True, None, None) 297 | 298 | def recursive_will_execute(prompt, outputs, current_item, memo={}): 299 | unique_id = current_item 300 | 301 | if unique_id in memo: 302 | return memo[unique_id] 303 | 304 | inputs = prompt[unique_id]['inputs'] 305 | will_execute = [] 306 | if unique_id in outputs: 307 | return [] 308 | 309 | for x in inputs: 310 | input_data = inputs[x] 311 | if isinstance(input_data, list): 312 | input_unique_id = input_data[0] 313 | output_index = input_data[1] 314 | if input_unique_id not in outputs: 315 | will_execute += recursive_will_execute(prompt, outputs, input_unique_id, memo) 316 | 317 | memo[unique_id] = will_execute + [unique_id] 318 | return memo[unique_id] 319 | 320 | def recursive_output_delete_if_changed(prompt, old_prompt, outputs, current_item): 321 | unique_id = current_item 322 | inputs = prompt[unique_id]['inputs'] 323 | class_type = prompt[unique_id]['class_type'] 324 | class_def = nodes.NODE_CLASS_MAPPINGS[class_type] 325 | 326 | is_changed_old = '' 327 | is_changed = '' 328 | to_delete = False 329 | if hasattr(class_def, 'IS_CHANGED'): 330 | if unique_id in old_prompt and 'is_changed' in old_prompt[unique_id]: 331 | is_changed_old = old_prompt[unique_id]['is_changed'] 332 | if 'is_changed' not in prompt[unique_id]: 333 | input_data_all = get_input_data(inputs, class_def, unique_id, outputs) 334 | if input_data_all is not None: 335 | try: 336 | #is_changed = class_def.IS_CHANGED(**input_data_all) 337 | is_changed = map_node_over_list(class_def, input_data_all, "IS_CHANGED") 338 | prompt[unique_id]['is_changed'] = is_changed 339 | except: 340 | to_delete = True 341 | else: 342 | is_changed = prompt[unique_id]['is_changed'] 343 | 344 | if unique_id not in outputs: 345 | return True 346 | 347 | if not to_delete: 348 | if is_changed != is_changed_old: 349 | to_delete = True 350 | elif unique_id not in old_prompt: 351 | to_delete = True 352 | elif inputs == old_prompt[unique_id]['inputs']: 353 | for x in inputs: 354 | input_data = inputs[x] 355 | 356 | if isinstance(input_data, list): 357 | input_unique_id = input_data[0] 358 | output_index = input_data[1] 359 | if input_unique_id in outputs: 360 | to_delete = recursive_output_delete_if_changed(prompt, old_prompt, outputs, input_unique_id) 361 | else: 362 | to_delete = True 363 | if to_delete: 364 | break 365 | else: 366 | to_delete = True 367 | 368 | if to_delete: 369 | d = outputs.pop(unique_id) 370 | del d 371 | return to_delete 372 | 373 | 374 | class xyExecutor: 375 | def __init__(self): 376 | self.reset() 377 | 378 | def reset(self): 379 | self.outputs = {} 380 | self.object_storage = {} 381 | self.outputs_ui = {} 382 | self.status_messages = [] 383 | self.success = True 384 | self.old_prompt = {} 385 | 386 | def add_message(self, event, data, broadcast: bool): 387 | self.status_messages.append((event, data)) 388 | 389 | def handle_execution_error(self, prompt_id, prompt, current_outputs, executed, error, ex): 390 | node_id = error["node_id"] 391 | class_type = prompt[node_id]["class_type"] 392 | 393 | # First, send back the status to the frontend depending 394 | # on the exception type 395 | if isinstance(ex, comfy.model_management.InterruptProcessingException): 396 | mes = { 397 | "prompt_id": prompt_id, 398 | "node_id": node_id, 399 | "node_type": class_type, 400 | "executed": list(executed), 401 | } 402 | self.add_message("execution_interrupted", mes, broadcast=True) 403 | else: 404 | mes = { 405 | "prompt_id": prompt_id, 406 | "node_id": node_id, 407 | "node_type": class_type, 408 | "executed": list(executed), 409 | 410 | "exception_message": error["exception_message"], 411 | "exception_type": error["exception_type"], 412 | "traceback": error["traceback"], 413 | "current_inputs": error["current_inputs"], 414 | "current_outputs": error["current_outputs"], 415 | } 416 | self.add_message("execution_error", mes, broadcast=False) 417 | 418 | # Next, remove the subsequent outputs since they will not be executed 419 | to_delete = [] 420 | for o in self.outputs: 421 | if (o not in current_outputs) and (o not in executed): 422 | to_delete += [o] 423 | if o in self.old_prompt: 424 | d = self.old_prompt.pop(o) 425 | del d 426 | for o in to_delete: 427 | d = self.outputs.pop(o) 428 | del d 429 | 430 | raise Exception(ex) 431 | 432 | def execute(self, prompt, prompt_id, extra_data={}, execute_outputs=[]): 433 | nodes.interrupt_processing(False) 434 | 435 | self.status_messages = [] 436 | self.add_message("execution_start", { "prompt_id": prompt_id}, broadcast=False) 437 | 438 | with torch.inference_mode(): 439 | #delete cached outputs if nodes don't exist for them 440 | to_delete = [] 441 | for o in self.outputs: 442 | if o not in prompt: 443 | to_delete += [o] 444 | for o in to_delete: 445 | d = self.outputs.pop(o) 446 | del d 447 | to_delete = [] 448 | for o in self.object_storage: 449 | if o[0] not in prompt: 450 | to_delete += [o] 451 | else: 452 | p = prompt[o[0]] 453 | if o[1] != p['class_type']: 454 | to_delete += [o] 455 | for o in to_delete: 456 | d = self.object_storage.pop(o) 457 | del d 458 | 459 | for x in prompt: 460 | recursive_output_delete_if_changed(prompt, self.old_prompt, self.outputs, x) 461 | 462 | current_outputs = set(self.outputs.keys()) 463 | for x in list(self.outputs_ui.keys()): 464 | if x not in current_outputs: 465 | d = self.outputs_ui.pop(x) 466 | del d 467 | 468 | comfy.model_management.cleanup_models() 469 | self.add_message("execution_cached", 470 | { "nodes": list(current_outputs) , "prompt_id": prompt_id}, 471 | broadcast=False) 472 | executed = set() 473 | output_node_id = None 474 | to_execute = [] 475 | 476 | for node_id in list(execute_outputs): 477 | to_execute += [(0, node_id)] 478 | 479 | while len(to_execute) > 0: 480 | #always execute the output that depends on the least amount of unexecuted nodes first 481 | memo = {} 482 | to_execute = sorted(list(map(lambda a: (len(recursive_will_execute(prompt, self.outputs, a[-1], memo)), a[-1]), to_execute))) 483 | output_node_id = to_execute.pop(0)[-1] 484 | 485 | # This call shouldn't raise anything if there's an error deep in 486 | # the actual SD code, instead it will report the node where the 487 | # error was raised 488 | self.success, error, ex = recursive_execute(prompt, self.outputs, output_node_id, extra_data, executed, prompt_id, self.outputs_ui, self.object_storage) 489 | if self.success is not True: 490 | self.handle_execution_error(prompt_id, prompt, current_outputs, executed, error, ex) 491 | break 492 | 493 | for x in executed: 494 | self.old_prompt[x] = copy.deepcopy(prompt[x]) 495 | 496 | if comfy.model_management.DISABLE_SMART_MEMORY: 497 | comfy.model_management.unload_all_models() 498 | -------------------------------------------------------------------------------- /ttNpy/ttNserver.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from aiohttp import web 5 | 6 | import folder_paths 7 | from server import PromptServer 8 | 9 | routes = PromptServer.instance.routes 10 | 11 | @routes.get("/ttN/reboot") 12 | def restart(self): 13 | try: 14 | sys.stdout.close_log() 15 | except Exception as e: 16 | pass 17 | 18 | print(f"\nRestarting...\n\n") 19 | if sys.platform.startswith('win32'): 20 | return os.execv(sys.executable, ['"' + sys.executable + '"', '"' + sys.argv[0] + '"'] + sys.argv[1:]) 21 | else: 22 | return os.execv(sys.executable, [sys.executable] + sys.argv) 23 | 24 | @routes.get("/ttN/models") 25 | def get_models(self): 26 | ckpts = folder_paths.get_filename_list("checkpoints") 27 | return web.json_response(list(map(lambda a: os.path.splitext(a)[0], ckpts))) 28 | 29 | @routes.get("/ttN/loras") 30 | def get_loras(self): 31 | loras = folder_paths.get_filename_list("loras") 32 | return web.json_response(loras) -------------------------------------------------------------------------------- /ttNpy/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import folder_paths 5 | 6 | class CC: 7 | CLEAN = '\33[0m' 8 | BOLD = '\33[1m' 9 | ITALIC = '\33[3m' 10 | UNDERLINE = '\33[4m' 11 | BLINK = '\33[5m' 12 | BLINK2 = '\33[6m' 13 | SELECTED = '\33[7m' 14 | 15 | BLACK = '\33[30m' 16 | RED = '\33[31m' 17 | GREEN = '\33[32m' 18 | YELLOW = '\33[33m' 19 | BLUE = '\33[34m' 20 | VIOLET = '\33[35m' 21 | BEIGE = '\33[36m' 22 | WHITE = '\33[37m' 23 | 24 | GREY = '\33[90m' 25 | LIGHTRED = '\33[91m' 26 | LIGHTGREEN = '\33[92m' 27 | LIGHTYELLOW = '\33[93m' 28 | LIGHTBLUE = '\33[94m' 29 | LIGHTVIOLET = '\33[95m' 30 | LIGHTBEIGE = '\33[96m' 31 | LIGHTWHITE = '\33[97m' 32 | 33 | class ttNl: 34 | def __init__(self, input_string): 35 | self.header_value = f'{CC.LIGHTGREEN}[ttN] {CC.GREEN}' 36 | self.label_value = '' 37 | self.title_value = '' 38 | self.input_string = f'{input_string}{CC.CLEAN}' 39 | 40 | def h(self, header_value): 41 | self.header_value = f'{CC.LIGHTGREEN}[{header_value}] {CC.GREEN}' 42 | return self 43 | 44 | def full(self): 45 | self.h('tinyterraNodes') 46 | return self 47 | 48 | def success(self): 49 | self.label_value = f'Success: ' 50 | return self 51 | 52 | def warn(self): 53 | self.label_value = f'{CC.RED}Warning:{CC.LIGHTRED} ' 54 | return self 55 | 56 | def error(self): 57 | self.label_value = f'{CC.LIGHTRED}ERROR:{CC.RED} ' 58 | return self 59 | 60 | def t(self, title_value): 61 | self.title_value = f'{title_value}:{CC.CLEAN} ' 62 | return self 63 | 64 | def p(self): 65 | print(self.header_value + self.label_value + self.title_value + self.input_string) 66 | return self 67 | 68 | def interrupt(self, msg): 69 | raise Exception(msg) 70 | 71 | class ttNpaths: 72 | ComfyUI = folder_paths.base_path 73 | tinyterraNodes = Path(__file__).parent.parent 74 | font_path = os.path.join(tinyterraNodes, 'arial.ttf') 75 | 76 | class AnyType(str): 77 | """A special class that is always equal in not equal comparisons. Credit to pythongosssss""" 78 | 79 | def __eq__(self, _) -> bool: 80 | return True 81 | 82 | def __ne__(self, __value: object) -> bool: 83 | return False --------------------------------------------------------------------------------