416 Commits
Author SHA1 Message Date
Regis Gaughan, III 602ae5ea45 Merge pull request #777 from unclehooru/fix-mode-state-restore
Fix bypass/mute state lost on tab switch for rgthree nodes, and Repeater overwriting linked nodes on restore
2026-10-05 15:27:29 -04:00
unclehooru 8863b9e658 Move prototype accessor lookup into shared_utils as a generic helper 2026-10-05 17:05:16 +09:00
unclehooru 10f3769d37 Merge remote-tracking branch 'upstream/main' into fix-mode-state-restore 2026-10-05 15:51:23 +09:00
Regis Gaughan, III 55ee1c7b26 Merge pull request #776 from cmdicely/bugfix/775-power-puter-python-314-compatibility
Power Puter: Avoid relying on obsolete `ast.Num` after its removal
2026-10-05 01:00:12 -04:00
rgthree 16e329820f Update package deps and types; fix broken types, even if still not the latest comfyu typings... 2026-10-05 00:29:21 -04:00
unclehooru e51609a9ae Do not rewrite linked nodes from Repeater/Relay while a workflow is being restored 2026-10-01 22:52:16 +09:00
unclehooru 20cdd32604 Keep _state.mode in sync when overriding mode in RgthreeBaseNode 2026-10-01 22:51:24 +09:00
cmdicely beb832316c Update power_puter.py
use `ast.Contant.value` instead of `ast.Constant.n` in python 3.14+

`ast.Constant.n` was a compatibility shim for `ast.Num` on `ast.Constant`.

It was removed in python 3.14 along with `ast.Num`.
2026-09-26 13:57:27 -07:00
cmdicely 44eb0cef0b Avoid relying on obsolete ast.Num after its removal
`ast.Num` was deprecated in Python 3.8 and removed in Python 3.14.

Check if `ast.Num` exists; rely only on `ast.Constant`, which replaced it, otherwise.
2026-09-26 13:20:57 -07:00
Regis Gaughan, III 449c58fcdd Merge pull request #769 from stuttlepress/fix-image-comparer-empty-output
Fix Image Comparer never populating its inherited IMAGE output.
2026-09-24 22:34:17 -04:00
Regis Gaughan, III bec8e6eaa0 Merge pull request #770 from DrJKL/fix/fast-groups-toggle-restrictions
Fix Fast Groups toggle restrictions after widget adoption. #766.
2026-09-24 22:22:25 -04:00
Regis Gaughan, III 1554cb807b Merge pull request #763 from ussoewwin/fix/logo-route-non-svg
fix(logo): don't let a non-SVG / proxy error response break the logo route
2026-09-24 22:15:44 -04:00
AmpandAlexander Brown 7e03436f59 fix: preserve fast group toggle restrictions after widget adoption
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c564-c177-701a-b053-86d219fe1663
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-09-22 18:44:51 +00:00
stuttlepress 66341c6c8b Fix Image Comparer never populating its inherited IMAGE output.
The images output is never filled, and any link from it fails at runtime.

This also blocks the node inside ComfyUI's native Start Loop / End Loop, where
a node in the loop body must reach End Loop to pass validation and needs a
working output to connect to its terminations input.

Two optional inputs share one socket, so image_a is preferred and image_b is
the fallback.
2026-09-20 21:04:55 -05:00
ussoewwin 17f184820b fix(logo): don't let a non-SVG / proxy error response break the logo route
get_logo_svg() cached whatever LOGO_URL returned without validating it, and get_logo() then ran str.format() over it. Any literal '{' in the payload - an HTML error page from a proxy, captive portal or CDN, e.g. a Cloudflare 523 page whose CSS uses braces - raised ValueError: unexpected '{' in field name and broke the route for every request until restart.

Validate that the fetched markup is SVG, fall back to the bundled web/common/media/rgthree.svg, substitute {bg}/{fg} with str.replace instead of str.format, and never serve non-SVG content from the route.
2026-09-14 20:57:18 +09:00
rgthree 2c5342a8cb Cache head branch for updating extensions from git. 2026-08-31 23:42:17 -04:00
rgthree 35c9f1e186 Fix ComfyUI changing loadApiJson to async without updated types. Fixes #760 2026-08-27 23:50:33 -04:00
rgthree 13b4399c00 Update some of the types, though can't go to current b/c of comfy npm bugs. 2026-08-21 00:19:33 -04:00
rgthree f4bf78648b Remove test invocation of previous message. 2026-08-19 23:46:05 -04:00
rgthree 65b9521230 Add a warning in the browser and console for ComfyUI_Swwan conflict as many are running into issues. 2026-08-19 23:44:51 -04:00
rgthree d92cad68a6 ComfyUI switched id types again; fixing drag and drop widgets check to allow for either. Fixes #755 2026-08-18 12:10:18 -04:00
rgthree 8f14800b3c Lora Loader fixes: mute video audio, add option for autoplay previews, fix wrapping in notes, etc. 2026-08-18 12:01:56 -04:00
rgthree 6b76ee6f2c Update the updater script to retry branches. 2026-07-23 21:29:38 -04:00
Regis Gaughan, III e1628b0619 Merge pull request #743 from hcwhan/fix-contextmenu-title-raw-html
Fix progress bar context menu title showing raw HTML on newer frontends
2026-07-23 19:34:16 -05:00
hcwhan 56a800962d Fix progress bar context menu title showing raw HTML on newer frontends
Newer ComfyUI frontends render the ContextMenu title via textContent instead of innerHTML (XSS hardening), so the HTML string passed as the progress bar menu title was displayed as raw markup. Pass a plain-text title and swap in the logo markup after the menu is created.
2026-07-17 17:38:56 +08:00
rgthree 27b4f4cdcf Adds now() strftime() and full datetime support for Power Puter 2026-06-20 00:20:56 -04:00
rgthree 738105af5f Ensure resource look ups exist under their respective directory. Fixes #725 2026-05-08 22:57:14 -04:00
rgthree 683836c46e Power Lora Loader: Add an info badge (and config option to remove); add ability to match selection 2026-04-07 00:17:26 -04:00
rgthree b38092ece9 Handle subgraph generation for seed node when inside a subgraph. Fixes #697 2026-04-06 23:57:09 -04:00
rgthree 1773555c7f Add sha256 function to Power Puter 2026-04-06 23:54:22 -04:00
rgthree c63a8c4015 Update comfy w/o getting stuck on non-changed changes. 2026-04-06 23:53:20 -04:00
rgthree 8ff50e4521 Correct operator usage and add some more. Fixed from copying in original source. Fixes #645 2025-12-11 20:53:25 -05:00
rgthree ce6fc334c9 Use the dynamic prompt when looking up nodes in Power Puter. Addresses #644 2025-12-09 21:37:29 -05:00
rgthree b6e0e4b33e Fix index access on lists in Power Puter and add some tests. Fixes #643 2025-12-07 17:18:00 -05:00
rgthree e9d5618188 Add and operators to Power PuPuter. Also, restrict filesystem save methods from numpy.ndarray. 2025-12-06 00:53:18 -05:00
rgthree 42e73c3c48 Add a log message for ComfyUI's Node 2.0 incompatibility. 2025-11-27 08:46:36 -05:00
rgthree eadc24ecc8 Fix cases where a missing lora would result in NPE for abspath lookup. Fixes #620 2025-11-11 19:55:20 -05:00
rgthree 68fe47c0e8 Resolve symlinks (and userdirs) when looking for lora files reported by ComfyUI. Addresses #574 2025-11-10 23:55:04 -05:00
rgthree 9d8164edfc Safety check the Power Lora Loader info lookup, and log if there's an error to help #574 2025-11-09 19:59:33 -05:00
rgthree 4a1ddc3262 Remove no longer needed type declarations for button, buttonGroup and popup. 2025-11-08 22:23:02 -05:00
rgthree 9cd266a43e Ports the button, buttongroup, and popup as ComfyUI is removing the ability to call these soon. Fixes #600. 2025-11-08 22:18:21 -05:00
rgthree 67c3ba1b7c Update ComfyUI frontend types and changes needed to accomodate them. Should be a no-op. 2025-11-08 18:14:45 -05:00
rgthree 629c514a68 Ports the widgetInputs.js methods used in reroute to support primitive nodes as ComfyUI is removing the ability to call these soon. Addresses #600. 2025-11-07 21:42:59 -05:00
Regis Gaughan, III 3faa4d0b4c Merge pull request #608 from sfinktah/sfink-label-multiline-rotate label: added \n parsing & rotation 2025-11-04 19:19:43 -05:00
Christopher Anderson 69b3bbd8b5 label: added \n & rotation [incl .js] 2025-11-05 00:14:16 +11:00
Christopher Anderson 3c1ff0cbf7 label: added \n parsing & rotation 2025-11-02 04:53:57 +11:00
rgthree 2b9eb36d3e Power Puter: batch() special built-in, and sync triggers() loading and fix invalid svg attrs. Fixes #590 and #592. 2025-10-05 20:58:19 -04:00
rgthree 0fb1e239a9 Power Puter: Add while loops, break, continue (and pass). Cleanup extraneous print debug. 2025-09-09 20:31:18 -04:00
rgthree 26988df02c Fix native 'queue selected nodes'; add random seed range in node properties; fix groups collecting nodes when moving; and other small fixes. Fixes #578 #536 #551 #576 2025-09-06 22:12:40 -04:00
rgthree dbc5fa5e89 Key node bounds with graph id as, turns out, node ids _can_ be duplicated across subgraphs. Fixes #568 2025-08-24 16:58:48 -04:00
rgthree 110e4ef1db If loading from API JSON manually inspect the loaded data and set widgets since ComfyUI can't automatically. Fixes #564 2025-08-19 21:21:51 -04:00
rgthree e5b2772686 Switch back to non-comfyUI removeWidget to fix power primitive textarea sticking even when not a string type. 2025-08-18 23:34:03 -04:00
rgthree 22afa45bf9 Turns ou, graph.subgraphs is an optional property; maybe older comfyuis. Fixes #561 2025-08-18 08:54:38 -04:00
rgthree baf11d1173 More subgraph cleanup. 2025-08-16 22:19:31 -04:00
rgthree 5b45e105c0 Add support for bookmarks in sub graphs. 2025-08-16 15:50:20 -04:00
rgthree 006cb572fa Fix fast muter/bypasser connections andsome other subgraph support. Fixes #558 2025-08-16 14:14:49 -04:00
rgthree 454e645b74 Fast Groups Muter/Bypasser now works with subgraphs; Fix group quick actions in subgraphs; some other subgraph cleanup too. 2025-08-16 13:58:54 -04:00
rgthree 9bbb1aced5 Add back in original removeWidget implementation as ComfyUI's new one isn't available yet. Fixes #559 2025-08-16 11:13:31 -04:00
rgthree fbbc2b8536 Add util method to change mode of nodes so we can manually update subgraphs too. Fixes #556 2025-08-16 01:32:58 -04:00
rgthree 20f3c0e4ef Update comfy ui frontend types after manually changing typedefs to export what we need. 2025-08-16 01:21:43 -04:00
rgthree c5ffa43de4 Isolated the TreeSitter parsing and added ability to control exposed properties and methods. 2025-08-01 23:53:54 -04:00
rgthree 930d98a0ff Add initial code for a conductor node plus tree sitter. 2025-07-19 21:54:33 -04:00
rgthree 944d5353a1 Puter: Add ability to assign dict values using '{}'. 2025-07-11 23:02:21 -04:00
Regis Gaughan, III 784c7d1dab Merge pull request #532 from phazei/power-loader-fixes
Power Lora Loader: make model optional and include full path in get_e…
2025-07-11 22:39:38 -04:00
rgthree 07c5eab7c7 Add slice to power puter abilities. 2025-07-08 00:31:08 -04:00
rgthree 3d0ed3f54c Allow deterministic/cacheable use of random.choice and random.int by using random.seed first. 2025-07-07 23:40:17 -04:00
rgthree 4595787e72 Fix seed to work with string ids (for subgraphs) 2025-07-07 21:30:46 -04:00
Adam 98b16c4a01 Power Lora Loader: make model optional and include full path in get_enabled_loras_from_prompt_node 2025-07-05 23:56:44 -07:00
rgthree f754c47658 Power Puter: Add for loop, and augassign capabilities. 2025-06-23 00:15:18 -04:00
rgthree 51bc089334 Switch Power Puter and Power Primitive to use BOOLEAN, not BOOL. Fixes #520 2025-06-22 22:49:44 -04:00
rgthree 417d485ac8 [Power Puter] Add purge_vram special function. 2025-06-22 22:01:32 -04:00
rgthree 6ea4040f27 Add video previews to lora loader info dialog. 2025-06-22 21:55:32 -04:00
rgthree 84a146fee3 Inc version 2025-06-08 12:10:06 -04:00
rgthree 74eeb5472c Remove kw_only for 3.9 compatibility. For #511 2025-06-08 12:09:33 -04:00
rgthree 569f90a7a5 Use typing Union/Optional for python 3.9 support. 2025-06-06 20:38:42 -04:00
Regis Gaughan, III 7a3910163d Merge pull request #506 from Mirimachina/main
Link to additional Power Puter docs in Readme
2025-06-05 22:49:43 -04:00
rgthree 9a40abd0e0 Power Puter: Add ability to define multiple outputs. Also improve button widget. 2025-06-05 22:47:55 -04:00
mirimachina 2fcd7a6421 Link to additional Power Puter docs in Readme 2025-05-30 20:37:10 -07:00
rgthree b775441a4c Power Puter: Allow lists and tuples to be used as iters within list comprehension 2025-05-29 00:26:42 -04:00
rgthree 6096e515f2 A bunch of common utils and such; especially around model/lora info. 2025-05-27 21:00:21 -04:00
rgthree 0aa6d0a203 Fix alphabet list. 2025-05-26 07:47:33 -04:00
rgthree dd4c0ca27b Power Puter: Pre-set input vars (a-z) to avoid key errors when muting/bypassing. Add better messages for trigger words. 2025-05-26 07:42:22 -04:00
rgthree 9c01f224a8 Allow CLIP as optional for Power Lora Loader. Fixes #501 2025-05-24 21:29:35 -04:00
rgthree 9774135656 Power Puter: Allow assigning to destructured tuple 2025-05-24 18:35:03 -04:00
rgthree 61b82b068b Fixes regex pattern not annotated. Fixes #428 2025-05-24 16:32:54 -04:00
rgthree e349f57edc Power Puter: Allow passing anything through. Mostuseful to pass the value of an input when it's not a primitive (like model, conditioning, etc.) 2025-05-24 16:17:45 -04:00
rgthree 0cf25c9205 Power Puter: Add len() as available function; opens door for deterministic randomization from external int/seed 2025-05-24 15:59:42 -04:00
rgthree 08fe395114 Power Puter: Better cache handling for connected nodes; Add print method. 2025-05-24 15:34:01 -04:00
rgthree 2aa1a855e8 Power Puter: Add condition support, passing arguments to special functions, and get trigger words for enabled loras, get connected nodes. 2025-05-22 22:49:38 -04:00
rgthree 1b1a7359bf Power Puter: Allow special function calls from list comprehension. 2025-05-21 21:36:59 -04:00
rgthree 8731686ac1 Power Puter: Add re() method for regex compilation; Allow searching for nodes names by regex; better recursive handling of multi-looped list comprehsion; breaking change: nodes() now returns a lit. 2025-05-21 21:10:19 -04:00
rgthree 5288408220 Save the Display Any response to the pnginfo so it pre-exists when reloading the workflow. 2025-05-18 23:59:31 -04:00
rgthree 942e290eeb Cast f-string vars into str first before joining. 2025-05-18 23:13:54 -04:00
rgthree ed4e3cccd2 Power Puter: Search for nodes by title; get a dict of nodes; and special fn for getting loras in power lora loader. 2025-05-18 22:59:30 -04:00
rgthree 8c122b3477 "Add version info and badge to about. Make pyproject the source of truth (name, icon, etc)." 2025-05-18 21:46:02 -04:00
rgthree f72f39d482 Fix Portainer not reseting colors. Fixes #494 2025-05-18 13:02:42 -04:00
rgthree 847c6345e0 Fixes new image size node; adds tests to verify. 2025-05-17 19:04:43 -04:00
rgthree 777cfaabc6 New node: Power Puter 2025-05-17 00:54:13 -04:00
rgthree 701cf7888a Add a simpler image resize node. 2025-05-15 23:13:20 -04:00
rgthree 6c5f7c95ed Add new files. (I guess -a doesn't add new files...) 2025-05-11 22:10:00 -04:00
rgthree 340533d536 New node to get the width height of an image, latent or mask. 2025-05-11 22:09:27 -04:00
rgthree 4fa340d667 Don't splice to return of previous getExtraMenuOptions. Fixes #495 2025-05-11 21:16:04 -04:00
rgthree 57cbded462 Add power primitive py, oops 2025-05-11 08:50:17 -04:00
rgthree 2d2fe9d48d Power Primitive 2025-05-10 23:19:14 -04:00
rgthree 5a0ce4f4ee New Node: Power Primitive. 2025-05-10 21:56:42 -04:00
rgthree aa6c75a30b Remove non-nullability assertions from link_fixer. 2025-05-07 22:41:49 -04:00
rgthree 96aad0528c build from source. 2025-05-07 20:17:11 -04:00
Regis Gaughan, III 41398d7969 Merge pull request #489 from huchenlei/huchenlei-patch-1
Fix link exist check in link_fixer
2025-05-07 20:16:19 -04:00
Chenlei Hu ba63b5fa8c Fix link exist check 2025-05-07 14:11:16 -04:00
rgthree e73edeed1a Rewrite and fix some inconsistencies with the link_fixer. Fixes #483 2025-05-07 00:07:22 -04:00
rgthree 5dc53323e0 Hacks around a bug in ComfyUI Frontend with cyclical configuration data. Addresses #471 2025-04-18 21:01:25 -04:00
rgthree b84f39c7c2 Don't return menu options when splicing despite bad typing. Fixes #462 2025-04-06 21:41:47 -04:00
rgthree b8a9f94640 Fix ComfyUI not calling draw on widgets with non-custom types any longer. Fixes #460 2025-04-04 21:22:50 -04:00
rgthree d37740b0db Pull some changes from dev to main 2025-03-29 21:09:49 -04:00
rgthree f044a9dbb3 Adds the comfyorg/comfyui-frontend-types to the codebase. 2025-03-29 14:49:35 -04:00
rgthree d10b7c87f4 Pull in actual litegraph.js types, replacing rgthree-comfy version. 2025-03-28 22:48:42 -04:00
rgthree cb36126240 Fix case where the link type is not defined (which shouldn't happen but was in #447) 2025-03-25 23:00:35 -04:00
rgthree 3d30a6de68 Fix reroute nodes that stopped positioning their inputs/outputs after ComfyOrg/litegraph deprecated getConnectionPos. Addresses #447 and #449 2025-03-25 22:36:22 -04:00
rgthree ab37a0bd37 Cleanup debugging wrapper. 2025-03-17 21:10:55 -04:00
rgthree 4c87142dda Handle change in ComfyUI widget config dropping minWidth/minHeight. Fixes #443 2025-03-17 21:08:16 -04:00
rgthree 32142fe476 Fixes link_fixer which broke when ComfyUI frontend started bundling code rather than serving independant modules. Helps #371 2025-02-21 20:42:49 -05:00
rgthree 31b784bac4 Ensures weird LiteGraph submenu calling also calls extensions' overrides. Fixes #369 and #417 2025-02-17 18:37:26 -05:00
rgthree 5d771b8b56 Add pyproject for comfy registry. 2024-12-30 23:01:44 -05:00
Regis Gaughan, III d7d5c92754 Create publish_comfy_registry_action.yml 2024-12-30 22:56:52 -05:00
rgthree 4fc1d9232f Add to Power Prompt widgets. Fixes #396 2024-12-22 22:36:36 -05:00
rgthree 53724bfab2 Release keys pressed during SHIFT when SHIFT is released. Fixes #376 2024-12-22 22:20:09 -05:00
rgthree 5f2d8a1d19 Forgot to remove saved prompts from simple power pompt. Fixes #402 2024-12-19 00:57:51 -05:00
rgthree 626fc04911 Remove saved prompts; not sure they ever worked, and cause issues for some installs. Addresses #398 and #399 2024-12-18 21:23:24 -05:00
rgthree e6ae355bb8 Only mark dirty during widget stabilization if something has changed. Addresses #397 2024-12-14 13:50:26 -05:00
rgthree 70756c044d No need to JSON.dump primitive values for display any. Prob fixes #360. 2024-11-29 15:04:32 -05:00
rgthree 6d22bbf450 Add back the overlay to show current down keys for #376 2024-11-24 15:58:18 -05:00
rgthree 8df5e33109 Use passed value 'to' for mode change rather than accessing this.mode (as other getters may be in the way). Partly for #362 2024-11-05 23:09:30 -05:00
rgthree 6f82a5c72f When dragging onto a node, don't let LiteGraph override title to default and check there's a workflow in the file before asking to replace workflow. Fixes #350 2024-10-11 21:41:45 -04:00
rgthree 206a6fac41 Handle breaking changes in LGraphNode that unow that uses defineProperty. Fixes #349 and #351 2024-10-11 21:20:17 -04:00
rgthree d5a2f47a58 Fix forward changes to accomodate Progress Bar positioning with ComfyUI_frontend changes in 1.3.x. Fixes #339. 2024-10-01 21:09:19 -04:00
rgthree acfc491cbd Manual Merge enhancements from other branches; Refresh config, fix regular clip encode; and cleanup server routes and utils. 2024-10-01 00:13:51 -04:00
rgthree 7924df4c4c Remove the no longer needed execution optimization and messaging. 2024-09-26 21:40:12 -04:00
rgthree 083ad2aebb Add a fast-group toggle for queue'ing the group output nodes. Fixes #320 2024-09-15 17:36:45 -04:00
rgthree 98f7a0524b If we get a special seed to seed backend node, then force it to be 'changed'. Fixes #326 2024-09-11 22:17:04 -04:00
rgthree cae8e2ad28 Fix issue with comfy ui's popup behavoir for small menus on the bottomn. Fixes #306 2024-08-28 22:32:06 -04:00
rgthree 9b3b236ead Add titlebar back to display nodes; this also allows the id bading to appear. Fixes #309 2024-08-28 21:44:35 -04:00
rgthree bbd4f632d8 Fix missing tuple data from Dynamic Context merge. Fixes #312 2024-08-28 21:37:02 -04:00
rgthree bbcd1a73ad Makes IContextMenuItem.content optional because, I guess it is? Fixes #310 2024-08-28 20:53:49 -04:00
rgthree 17df78ec03 Fixes reroute fast-keys regression caused by ComfyUI's LiteGraph breaking changes. Fixes #300 2024-08-17 17:47:29 -04:00
rgthree dd534e5384 Parse json through several passes of strictness. Fixes #294 2024-08-10 20:40:00 -04:00
rgthree fb138ddf6f Update some infra, and add unreleased Dynamic Context work for testing. 2024-08-08 22:05:57 -04:00
rgthree 847ec8939d update to utils too. 2024-08-08 00:19:12 -04:00
rgthree 65e05d4782 Updating some common code from dev branches. 2024-08-08 00:02:37 -04:00
rgthree 8274753c3e Handle bad NaN from other extensions in saved pnginfo. 2024-08-06 23:19:10 -04:00
rgthree 03ced76b8c Add a menu item for Queuing a single Group. 2024-08-04 15:00:05 -04:00
rgthree c226c7deac Move standalone services to services dir. 2024-08-04 11:11:03 -04:00
rgthree 71ed4c4296 Add rgthree-comfy buttons to the top bar, and better capture key events now that Comfy has UI outside of LiteGraph. Other cleanup + fixes too. Fixes #286 2024-08-03 15:26:47 -04:00
rgthree 2c8ddf29ea Cleanup for settings up virtual nodes 2024-07-30 22:03:26 -04:00
rgthree 2613da28c6 better import handling and formatting pass. 2024-07-24 23:55:32 -04:00
rgthree 00841a8baf Some better typing and infra for integration testing. 2024-07-24 22:30:06 -04:00
rgthree 37d8759dbf Don't force body to be grid, some other extensions aren't ready. 2024-07-10 07:22:54 -04:00
rgthree fc7c2fde3e Force the grid display on body even when ComfyUI's new layout bar isn't used. Fixes #275 and #274 2024-07-09 20:50:50 -04:00
rgthree bb0232e817 Use updated comfyui page grid for progress bar. Fixes #261 2024-07-06 14:08:04 -04:00
rgthree 04f7e0cbc6 Comment changes to the LiteGraph type. 2024-07-03 00:39:18 -04:00
rgthree 258e735a70 Change Litegraph type to optional color and handle breakages. Fixes #266. 2024-07-03 00:36:10 -04:00
rgthree a7f34b16f8 Add nodes to rgthree-menu. Addresses #269 2024-07-02 23:41:52 -04:00
rgthree bd958e40a3 Make flexible inputs work with https://github.com/comfyanonymous/ComfyUI/pull/2666 2024-06-24 21:06:57 -04:00
rgthree bca23c6cdf Manually add context to nodes suggestion list because ComfyUI only does 'required' inputs. Fixes #257 2024-06-22 20:51:35 -04:00
rgthree d233e0fa5e Adds support for url-list node-drop support and also removes stray console.log. 2024-06-20 22:47:27 -04:00
rgthree 8c92a8a5d8 AnySwitch: Add dynamic number of inputs to Any Switch. 2024-06-20 22:29:27 -04:00
rgthree be08b0f875 New Feature: Import a single node's widgets' values on drag & drop. Off by default. 2024-06-19 21:58:37 -04:00
rgthree 45781c3dbb Allow infinite context inputs for Context Switch, Merge, and their Big nodes. Fixes #253 2024-06-17 23:20:39 -04:00
rgthree 45ad0ab813 Block from nodeCreated event on rgthree-comfy nodes until https://github.com/jags111/efficiency-nodes-comfyui/pull/203 is pulled and saturated. 2024-06-15 21:21:09 -04:00
rgthree bcdf53e726 Fix image comparer not updating open/copy images from menu. 2024-06-11 22:32:55 -04:00
rgthree ab0a59a70f Fix optional chain for unsupported browsers. 2024-06-10 22:48:28 -04:00
rgthree de9fb7bbe3 Move key processing to overloaded LiteGraph to ensure propagation; clear all keys on Mac when Command key is lifted. Fixes #238 2024-06-10 22:28:53 -04:00
rgthree 7b8ea34347 Add css vars for colors for custom themeing. For #239 2024-06-09 15:29:16 -04:00
rgthree 754107ccf6 Fix power lora loader now chaning first entry with mouse move. Fixes #245 2024-06-08 20:31:36 -04:00
rgthree 73da0aeb19 Adds debug key info to the UI in debug mode. For #238 2024-06-07 23:16:18 -04:00
rgthree c8603bc24a Comparer: Don't error on muted inputs & allow choosing from batch images in A and/or B. 2024-06-07 22:52:21 -04:00
rgthree 75fbffea40 Power Lora: Reset check for mouse move on mouse up. Fixes #245 2024-06-07 22:09:12 -04:00
rgthree a20c9aeebe Some enhancements for Power Lora Loader; Some None checks for lora info in server. Addresses #232 2024-05-21 20:52:20 -04:00
rgthree 23fddb87c8 Merge branch 'main' of https://github.com/rgthree/rgthree-comfy 2024-05-20 21:01:35 -04:00
rgthree 6f270eee23 Remove debug logs. 2024-05-20 21:01:30 -04:00
Regis Gaughan, III aa9f29f4d4 Merge pull request #200 from DrJKL/drjkl/bookmark-increment
Set bookmarks' default shortcut to an unused value,
2024-05-20 20:58:11 -04:00
DrJKL 738441aa19 Address comments :-) 2024-05-20 12:51:54 -07:00
DrJKL 9494cfc9bc Set bookmarks' default shortcut to an unused value, at least for the first 36... 2024-05-20 12:51:53 -07:00
rgthree d1b6ee84bc Add image data in civitai lora image previews. For #223 2024-05-18 00:10:20 -04:00
rgthree 5f86b99bc3 Some cleanup and new menu class. 2024-05-17 23:21:42 -04:00
rgthree 2fb14983b1 Remove console.log 2024-05-17 21:57:24 -04:00
rgthree a8dceb0d7b Add in safetensors metadata (trained words, base model file). For #223 2024-05-17 21:51:25 -04:00
rgthree 872d264df1 Fix reroute shortcuts splitting. Fixes #228 2024-05-16 17:32:09 -04:00
rgthree c7a4e6be13 QoL; For the bookmark shortcut key input, hijack keydown to determine the keys to press. 2024-05-16 17:24:35 -04:00
rgthree e0e3107948 Fix shift check when not all keys are down for bookmark. 2024-05-15 23:49:16 -04:00
rgthree f4eba67933 Enhance bookmarks to allow for multiple-key shortcuts, and make the key check exclusive (except for shift). Helps #227 2024-05-15 22:04:15 -04:00
rgthree ad6029145c Smoothing out some rought edges for power lora loader info dialog. 2024-05-15 00:26:23 -04:00
rgthree 05bf3d0502 dropped dep 2024-05-14 23:53:09 -04:00
rgthree 2d7f3d5a20 Add an info dialog to right-click menu in Power Lora Loader with support for same-named images and civitai lookup. 2024-05-14 23:51:49 -04:00
rgthree 9f532bd5a0 General updates from other branches 2024-05-06 20:12:08 -04:00
Regis Gaughan, III 017a1595cd Merge pull request #198 from DrJKL/drjkl/bookmark-label
Add bookmarks list menu
2024-05-06 19:52:58 -04:00
DrJKL d4cd774d7b Move and align feature config. 2024-05-05 19:48:27 -07:00
DrJKL 0aaba5e553 Rebuild post-merge 2024-05-05 19:48:27 -07:00
DrJKL 542d457958 Single Stream + Threequals 2024-05-05 19:48:27 -07:00
DrJKL 4d1c66ec0f Address comments :-) 2024-05-05 19:48:27 -07:00
DrJKL b61ac28b2f Add bookmarks list menu. Also adds a label property to them. Think it's worth exposing it on hover as a pseudo-title? 2024-05-05 19:48:27 -07:00
rgthree bf6255aa89 Only apply better box-sizing within rgthree-comfy components. Fixes #220 2024-05-05 09:38:55 -04:00
rgthree 2db6c4d73a When a relay has no inputs connected, use its own mode change as the relay signal. 2024-05-04 21:31:07 -04:00
rgthree 76638e2ddc New Node: A Label node allows you to add a floating label to your workflow. Can be pinned and customized. 2024-05-04 17:08:58 -04:00
rgthree 67e53859c0 Merge branch 'main' of https://github.com/rgthree/rgthree-comfy 2024-05-04 17:03:31 -04:00
Regis Gaughan, III f873e5f3b0 Merge pull request #208 from DrJKL/drjkl/windows-support-plz
Allows building on Windows
2024-05-04 15:27:51 -04:00
rgthree ae0dd6b7ba Some common cleanup from other branches. 2024-04-25 22:57:00 -04:00
DrJKL 69919454c9 Small tweaks to the build commands to allow it to work on Windows (since it doesn't support hashbang directives) 2024-04-21 16:33:18 -07:00
Regis Gaughan, III fdc1c4e3e4 Merge pull request #199 from DrJKL/drjkl/ignore-modifiers
Ignore keypresses if ctrl/option/alt is pressed.
2024-04-21 16:51:45 -04:00
rgthree e7f28a876d Try to backwards patch nodes outputs. 2024-04-20 14:56:45 -04:00
rgthree 9cb70ac3a0 Remove duplicate opt_connection from groups muter in workflows refreshed in last 12h. 2024-04-20 14:07:05 -04:00
rgthree 78539735e8 Check if we're constructed before calling onConstructed to avoid duplicate work in child classes. 2024-04-20 14:01:08 -04:00
DrJKL 6f7de0bd0d Build JS 2024-04-20 01:22:05 -07:00
DrJKL 281a25a91b Ignore keypresses if ctrl/option/alt is pressed. 2024-04-20 01:21:04 -07:00
rgthree d8ea2cb90f Set a comfyClass on rgthree-comfy virtual nodes (fixes other extensions' ), and some additional cleanup. Addresses #202 and #203 2024-04-19 23:29:33 -04:00
rgthree 516fe5a375 Scale auto submenus to match original menu when zoomed in. 2024-04-16 20:54:01 -04:00
rgthree db062961ed Refactor a bunch around server nodes to allow extensions to call nodeCreated since rgthree-comfy purposefully avoids the ComfyNode. Also cleans up logging. Addresses #194 2024-04-15 22:29:50 -04:00
rgthree 7fb84adce9 [Power Lora Loader] Add ability to choose between a single strength, or separate Model & Clip strengths in the node settings. Also, Add help text and some widget/canvas cleanup. Closes #191 2024-04-13 14:42:47 -04:00
rgthree 8151f22366 Return original call to app.loadGraphData (so awaiters can await). Fixes #193 2024-04-11 20:13:45 -04:00
rgthree 79f5fea259 remove debug print 2024-04-07 00:42:32 -04:00
rgthree f04afb725a Adds the (discouraged) ability to pass '-1' to the Seed node's server impl and use/save a new random seed. Addresses #188 2024-04-07 00:39:55 -04:00
rgthree 59618b9958 Keep power prompt lora logs silent when disabled. 2024-04-07 00:39:02 -04:00
rgthree 1d0e426418 New Node: Power Lora Loader! 2024-04-06 22:50:08 -04:00
rgthree a1263d0a7c Change seed node from swapping actual index value with just manipulating prompt sent to server. Fixes #185 and #169 2024-04-01 20:45:03 -04:00
rgthree ca7e9b4706 My handy update script. 2024-03-31 22:24:12 -04:00
rgthree aaf760fc56 Fix case for super-fast debugging workflow. 2024-03-31 16:47:36 -04:00
rgthree 7970e564b3 Rewrite seed widget using the RgthreeBaseServerNode. Also, change timing to the serialization/resetting to better handle batches. Maybe helps #169 2024-03-31 15:09:58 -04:00
rgthree d8ab5ce028 Fix seed to accomdate previous comfyui breaking serialize signature. 2024-03-31 13:51:49 -04:00
rgthree ba79379338 Remove build prompts_service 2024-03-31 13:48:32 -04:00
rgthree 3f05b6ddc9 Missing? 2024-03-30 21:24:37 -04:00
rgthree 47e921c66e Pulling in some generic updates from dev branches. 2024-03-30 21:22:52 -04:00
rgthree ecffebdb3a Add the Group Header toggles to README. 2024-03-30 00:24:59 -04:00
rgthree f8c01087f7 New Feature: Add quick mute and/or bypass icon toggles to group headers for one-click toggling when viewing groups. Off by default, turn on in rgthree-comfy's settings. 2024-03-29 23:48:03 -04:00
rgthree f72b9694f3 Don't recalculate group nodes if a group is actively moving. Fixes #181 2024-03-28 21:03:26 -04:00
rgthree 43b73e703c Fix relay setting wrong mode if overriding to 'nothing'. 2024-03-28 20:51:41 -04:00
rgthree cbf724a1c5 Adds auto nesting support for nested directories. Fixes #177 2024-03-26 22:37:15 -04:00
rgthree 9021670e9f Merge branch 'main' of https://github.com/rgthree/rgthree-comfy 2024-02-20 21:22:47 -05:00
rgthree c60802bf45 Fix comboboxes that surpass threshold but do not contain any folders. Fixes #143 2024-02-20 21:22:27 -05:00
Regis Gaughan, III 1b865b4521 Update README.md 2024-02-19 14:03:10 -05:00
rgthree 4f94342357 Adds a new feature to auto-nest subdirectories in long, flat combo lists. Experimental, turned off by default. Fixes #143 2024-02-18 17:55:54 -05:00
rgthree 79f6f24a95 Move 'Queue Selected Output Nodes' logic later in process, to api.queuePrompt override. Fixes #145 2024-02-18 15:06:16 -05:00
rgthree 185675dace Fix context menu for some overridden nodes. Fixes #137 2024-02-06 07:17:28 -05:00
rgthree cc881933c3 Add menu item for queueing only the selected nodes whenever there are selected output nodes. Fixes #132 2024-02-05 23:40:32 -05:00
rgthree 584f62322a Add top/bottom as options for progress bar. Resolves #131 2024-02-05 22:25:55 -05:00
rgthree 6aa039235f Make execution optimization even more compatible with future changes. 2024-01-31 22:14:36 -05:00
rgthree c9d8df2ec0 Rename iamge clipboard with menu prefix. 2024-01-30 22:35:48 -05:00
rgthree c716ddc9b6 New Feature: Option to queue a single output node _only_, via right-click menu 'Queue Node (rgthree)' 2024-01-30 22:32:44 -05:00
rgthree 05308342a3 Remove circular dependency for config service. Should help #81, issue 1.x 2024-01-30 07:42:40 -05:00
rgthree 62a6fea0c5 Add a counter for the number of passes a single node is making. Better experience for UltimateSDUpscale and IterativeImageUpscale, etc. 2024-01-28 23:06:54 -05:00
rgthree 5769311aea Add sass and have better output for building. 2024-01-26 23:53:13 -05:00
rgthree e07c470fb3 Use common rgthree path imports throughout src. 2024-01-26 22:27:29 -05:00
rgthree 060e4a320b Don't show relay caption when collapsed. Fixes #128 2024-01-26 22:19:09 -05:00
Regis Gaughan, III aaa0b2dddd Update README.md 2024-01-26 00:46:14 -05:00
Regis Gaughan, III 30ea11a028 Update README.md 2024-01-26 00:41:42 -05:00
rgthree 2ed9037f5a Adds a context menu to progress bar so people can access settings and turn it off if they'd like. 2024-01-25 23:51:05 -05:00
rgthree b04e7061f9 Launch the progress bar as a default feature, can be turned off in settings. 2024-01-25 22:06:08 -05:00
rgthree 551892bea6 Establishes configuration server endpoints and front end UI for toggling various config; including the execution optimization which no longer requires restart to toggle. 2024-01-24 22:16:13 -05:00
rgthree 08ebe03eb2 Change reroute conversion to allow converting only reroutes in the current selection, and handle a bad assumption in the old comfy reroutes by disallowing disconnection. Addresses #122 2024-01-24 21:33:35 -05:00
rgthree fe40e99354 Add a top, minimal progress bar web component to the UI, behind a config flag. 2024-01-21 23:15:08 -05:00
rgthree d57fb705bf Adds an rgthree-comfy context menu with option to convert all reroute nodes to rgthree-reroutes. Fixes #122 2024-01-19 23:08:06 -05:00
rgthree 59d6fc49e0 Separate a common directory, and fix typescript paths. 2024-01-18 23:28:18 -05:00
rgthree bdf6338ba5 Copy the default configuration layout when creating new reroutes. Actually fixes #114 2024-01-16 17:57:39 -05:00
Regis Gaughan, III f1f7dbad7f Update README formatting 2024-01-15 23:48:54 -05:00
rgthree a49e0f3eda Ensure we would only ever patch the optimization once, maybe addressing #112. 2024-01-15 23:40:54 -05:00
rgthree a8ec2cd993 Add an explosed action for fast toggler nodes to flip all toggles. Addresses #98 2024-01-15 15:51:42 -05:00
rgthree 06a67bfd74 When going to a group from a fast groups node, zoom out if needed to see the whole group. Resolves #107. 2024-01-15 15:34:25 -05:00
rgthree 77a5f8faab Deep clone node properties on clone. Fixes #114 2024-01-15 15:09:15 -05:00
Regis Gaughan, III 7c9eddbd8b Merge pull request #113 from dotJack/patch-1
Fix issue with overwrite / passthrough of onSerialize
2024-01-14 14:22:13 -05:00
dotJack cedf2d8b88 Convert ts to js via __dev__.sh 2024-01-14 20:38:54 +02:00
Kristjan Pärt d363961f6a fix call to passthrough onSerialize
`data` is an object returned from litegraph, but apply expects an array of objects. Any downstream implementations will receive undefined. (at least I did)
2024-01-14 18:19:55 +02:00
rgthree e288314277 Fix exposed Bypass All action on Fast Groups Bypasser. Fixes #109 2024-01-13 14:15:01 -05:00
rgthree d136e81c8f Increase efficiency of Groups Muter/Bypasser calculations by abandoning LiteGraph's built in mechanism. 2024-01-13 14:04:12 -05:00
rgthree b32c8c4438 fix dev paths 2024-01-13 13:42:18 -05:00
rgthree ce277c8c54 rename web source directory. 2024-01-12 22:11:39 -05:00
rgthree 1695e28775 Fix input connection enforcement while in the middle of loading the api JSON. Fixes #105 2024-01-09 22:10:11 -05:00
rgthree 00039037c2 Make image comparer set its size with space for the canvas itself. Addresses #106 2024-01-09 21:53:52 -05:00
rgthree 9b0dd91d10 add a userdata directory to .gitignore 2024-01-09 21:45:17 -05:00
rgthree 7bd51549fa Adds the ability to change the relay signal, so one could essentially create an inverse relay. Fixes #102 2024-01-08 00:31:13 -05:00
rgthree ee7f7f2ed0 Add support for PrimitiveNodes for Reroutes. Fixes #101 2024-01-07 23:24:28 -05:00
rgthree 2dfdf7a6b4 Track when ComfyUI may re-register its to stop rgthree nodes from setting up its constructors more than once. Fixes #100 2024-01-07 00:49:08 -05:00
rgthree 839a59215d Fix typo and #99 2024-01-07 00:06:05 -05:00
rgthree 476b99a7a2 Better handle relative imports between hosted pages 2024-01-06 23:15:08 -05:00
rgthree 6c79e34727 Move extenion web directory inward to make room for other rgthree pages. 2024-01-06 22:45:35 -05:00
rgthree 70158f4cbd Fix reverse proxies on a different path 2024-01-03 20:53:09 -05:00
rgthree 446573e350 Allow untoggling a widget when always one restriction is enabled and other widgets are already enabld. 2024-01-02 23:04:04 -05:00
rgthree 5d410ffe09 Remove fast muter/bypass restriction enforcement when not through a toggle click, matching the fast groups muter/bypasser's functionality. 2024-01-02 23:00:18 -05:00
rgthree 19deacda83 Add ability to have a restriction of a maximum of (or always) one enabled widget in fast groups muter/bypasser. Also fixes #87 2024-01-02 22:55:37 -05:00
rgthree cd24fcf120 Add ability to have a restriction of a maximum of (or always) one enabled widget in fast muter/bypasser. Fixes #87 2024-01-02 22:24:15 -05:00
rgthree 0a8e618966 Tweak the fix to ComfyUI's conflicting z-indexes. Fixes #86 2024-01-02 21:03:06 -05:00
rgthree 03167650c1 Update Image Comparer help 2024-01-02 20:57:55 -05:00
rgthree 838e244d3b Choose image A or B the right-click menu pertains to depending on which side of the half you right click. 2024-01-02 20:47:28 -05:00
rgthree 7987281653 Add a 'Copy Image' context menu item for nodes with an image and when clipboard looks like its supported. 2024-01-01 22:19:21 -05:00
rgthree f3cce4162b Add help for the Image Comparer node. 2024-01-01 18:20:00 -05:00
rgthree d69709ebf3 Image Comparer gets two modes (click and slide); Also, save the image paths to widget so it will come back when refreshing. 2024-01-01 18:09:25 -05:00
rgthree c54edb5a5f New Node: Image Comparer for comparing two images in a single view. Works by holding mouse down (for now) 2024-01-01 14:43:00 -05:00
rgthree 84cb989ca3 Don't manipulate inputs for base-any-input nodes if we're currently copying many to clipboard. Fixes #76 2023-12-29 12:49:14 -05:00
rgthree 2077934184 Context Merge is supposed to merge latter contexts over previous. 2023-12-10 17:38:05 -05:00
rgthree a0e9f628a3 Add a zoom option to bookmarks and cleanup typings for widgets. Fixes #73 2023-12-10 14:37:22 -05:00
rgthree c45b9e5e93 Fix shortcut_key not saving to workflows. Addresses part of #73 2023-12-10 13:50:44 -05:00
rgthree 14e78bb6ee Match existing toggle widgets for displaying text, etc. when zoomed out; don't allow groups nav if zoomed out too far (stop accidental clicks). Addresses part of #72 2023-12-09 13:39:10 -05:00
rgthree 15dfeb8b6a Fast Reroutes! New configurable shortcuts to quickly connect nodes through reroutes without letting go of the mouse. 2023-12-09 13:23:45 -05:00
rgthree 11c96b2ba6 Check for optional DragAndScale on provided canvas (that's not supposed to be able to be null). Should address error in #71, but couldn't repro locally. 2023-12-08 20:22:22 -05:00
rgthree 92da23e60a A new node: context merge node. 2023-12-06 22:47:53 -05:00
rgthree 3d276f3d64 Start seed node as -1 when newly created. 2023-12-06 21:49:10 -05:00
rgthree 423d59c311 Better collapsed width calc for context nodes. 2023-12-06 21:48:43 -05:00
rgthree fb4bfd820d Mark bookmark as virutal node. 2023-12-06 21:47:37 -05:00
rgthree 7ee15fa7ea flavor. 2023-12-06 01:52:02 -05:00
rgthree 52b38f7f44 A hacky workaround to compensate for upstream comfyui changes. May help #69, in a round-a-bout hacky way. 2023-12-06 01:51:12 -05:00
rgthree 4ba02790ec Add missing onKeyDown and onKeyUp to litegraph typings. 2023-12-05 22:33:07 -05:00
rgthree 518d8eccf8 Extend rgthree and dialog from eventtarget; add showHelp method to base node; 2023-12-05 22:28:12 -05:00
rgthree f612c8482f Add proper connection layout to any switch. Fix #68 2023-12-05 21:25:56 -05:00
rgthree 47d946765a Update Dev script for web subdir 2023-12-05 20:25:58 -05:00
rgthree d893175896 Optimize timing for newly added nodes; use requestAnimationFrame after a setTimeout in case browser coordinates that with painting. 2023-12-03 23:51:17 -05:00
rgthree 43b40bb931 Place shared groups logic into a singleton service that optimizes Fast Group Muter/Bypasser when there are multiple nodes. Mentioned in #65 2023-12-03 23:17:46 -05:00
rgthree a81daa2a16 No-op; move Reroute node's class outside of registerExtension 2023-12-03 00:36:55 -05:00
rgthree 5694a274f3 Add custom sort to fast group muter/bypasser. Fixes #65 2023-12-02 19:18:54 -05:00
rgthree eb7bb9ab3b Move existing help popups to new rgthree help dialogs. 2023-12-02 14:08:59 -05:00
rgthree 530bbb36a4 Standardize help menu for new nodes. Cleanup rendering for fast group (add ellipsis). 2023-12-02 13:53:45 -05:00
rgthree debb5d3f59 Mark Fast Groups Muter/Bypasser as a Virtual Node. Should fix #64 2023-12-02 11:03:36 -05:00
rgthree 5fe31c188d remove debug console logs 2023-12-02 00:12:10 -05:00
rgthree 817a91b30e Switch to WEB_DIRECTORY pattern. Fixes #63 2023-12-02 00:11:26 -05:00
rgthree 34b34175f9 Fix messaging in link_fixer 2023-12-01 23:54:07 -05:00
rgthree a2922602f5 Add help for bypasser 2023-12-01 23:45:54 -05:00
rgthree c10e7d4759 Help Dialog for Fast Groups Muter and generic support for more. 2023-12-01 23:14:40 -05:00
rgthree 189bf8e099 New node! Adds a Bookmark node, that can be placed anywhere in the workflow, and given a shortcut_key to move the viewport right to that node in the top-left corner. 2023-12-01 23:00:38 -05:00
rgthree 981a5eb275 Only swap seed widget values if ComfyUI is currently processing a queue. Fixes #62 2023-12-01 22:20:25 -05:00
rgthree 65832bcc83 Change glob.glob to be compatible with old Python versions. Fixes #59, #49 2023-12-01 00:18:32 -05:00
rgthree 887bd53c59 Better name option for alerting for corrupt workflows. 2023-11-30 23:44:14 -05:00
rgthree 39c66749c2 Make showing the UI for corrupt links optional and off by default. It can be a power-user thing. 2023-11-30 23:41:46 -05:00
rgthree d7cf0e393b New Node: Fast Groups Bypasser 2023-11-30 23:04:03 -05:00
rgthree a7e7c3f196 New Node: Fast Groups Muter! 2023-11-30 22:37:46 -05:00
rgthree 9f655b3fe8 Apply link_page fix to TypeScript src. 2023-11-30 22:37:20 -05:00
rgthree 8d15053a48 Call to the nodeType's getExtraMenuOptions as other extensions may override it on its way from the server. Fixes #60 for Context and other nodes. 2023-11-27 12:39:23 -05:00
rgthree 03ede18cbe Call old oldGetExtraMenuOptions for seed. Fixes #60 2023-11-27 12:20:38 -05:00
Regis Gaughan, III 9e83c296c5 Merge pull request #57 from mcmonkey4eva/main
fix import link, fixes #56
2023-11-18 23:50:05 -05:00
Alex "mcmonkey" Goodwin 3a01046617 fix import link 2023-11-18 20:28:46 -08:00
rgthree a197b650c3 Generalize the UI messaging in rgthree. 2023-10-22 17:59:31 -04:00
rgthree a6f6df5e3a Call out to from rgthree server nodes, for ComfyUI-Manager badges 2023-10-22 14:46:47 -04:00
rgthree 9efc63e729 Built typescript. 2023-10-17 22:13:40 -04:00
rgthree f7ab5bc179 Move some utilities to shared_utils and clean up widgets in base node. 2023-10-17 22:13:00 -04:00
rgthree aea87a98b0 Remove message for using a previous version of comfy. While this may still be desired for some, it should be OK to use HEAD for most. 2023-10-17 21:59:29 -04:00
rgthree f0f820f28d Merge branch 'main' of https://github.com/rgthree/rgthree-comfy 2023-10-17 21:57:06 -04:00
rgthree a3925b8024 Remove debug console.log 2023-10-17 21:54:50 -04:00
rgthree fc24ef272a Remove debug console.log 2023-10-17 21:54:34 -04:00
rgthree efebc58aab Add ability to connect all nodes from dragging input base_ctx, and unify output connection. Also, fix misspelling for CLIP_HEIGHT 2023-10-17 21:50:21 -04:00
Regis Gaughan, III c38183adf6 Merge pull request #45 from receyuki/patch-1
Fix seed decrement
2023-10-17 21:05:00 -04:00
Rhys Yang 4ca1d7be96 Fix seed decrement 2023-10-18 08:41:11 +08:00
Rhys Yang 05a6ff1db5 Fix seed decrement 2023-10-18 04:17:59 +08:00
rgthree cc23fe1f1e Divorce Context nodes from ComfyNode implementation to fix instability with inputs, types, and widgets. 2023-10-07 13:30:59 -04:00
rgthree 9972360927 Allow SDXL prompt to use non-SDXL conditioning if opt_width/opt_height is not passed. 2023-10-07 10:41:09 -04:00
rgthree 7d808129d4 Add message about recent breaking changes to README 2023-10-07 00:25:52 -04:00
rgthree c93998c0d4 Patch isValidConnection so old nodes can work with this new COMBO input type. 2023-10-06 23:23:48 -04:00
rgthree 58eef6f518 Remove debug 2023-10-06 22:43:36 -04:00
rgthree f48b991ba3 Move recursive optimization to __init__ so args work (since it requires execution.py). Fixes #42 2023-10-06 22:42:30 -04:00
rgthree 50dee1bfe6 Fix reroute node disconnecting from churned COMBO types (patch). Addresses #41 2023-10-06 12:41:11 -04:00
rgthree 0aa1c281d0 Fix options for dev build 2023-10-04 20:59:34 -04:00
rgthree 5e3d02ec6f Some style tweaks. Fixes #40 2023-10-04 20:40:37 -04:00
rgthree 72830f1c62 Optimize the recursive_will_execute to preserve the original comfy order, and speed up further by not concatenating lists (and just count instead). 2023-10-03 23:45:29 -04:00
rgthree 7b0062996d Make finding a lora stricter. Fixes #37 2023-10-03 23:41:37 -04:00
rgthree c13810262a Fix default connection layout for Reroute. 2023-10-03 22:35:50 -04:00
rgthree 43d11c7a25 Fix relative import issue for some machines. 2023-10-02 10:50:26 -04:00
rgthree f418be396b Fix bad config format. Fixes #34 2023-10-02 09:29:07 -04:00
rgthree 678e13a44c Don't allow reroute default sizes under 10 2023-10-01 22:25:05 -04:00
rgthree 3cbcce5277 Add some node config, starting with reroute node. Fixes #32 2023-10-01 22:12:58 -04:00
rgthree 3d95fd942e Add help menu item to the Random Unmuter node. 2023-10-01 17:19:34 -04:00
rgthree 882f407446 Adds a new node: Random Unmuter and addresses #31. Also adds ability to hook into prompting from rgthree global. 2023-10-01 16:52:50 -04:00
rgthree 0034199e76 Adds a 'Any Switch' that will choose the first non-null input. Fixes #26. Also, correct link colors on reroute input nodes 2023-10-01 00:10:02 -04:00
rgthree c01735c1b1 Use LiteGraph.isValidConnection to check Reroute node connectibility. Fixes #29 2023-09-30 20:12:39 -04:00
rgthree d7e62f754c Add ckpt name to Context Big. Fixes #24 2023-09-30 17:23:01 -04:00
rgthree 1f6e63d665 Avoid stabilizing inputs & outputs while the being configured, as connected nodes may delay instantiating slot types. Should fix #27 2023-09-18 22:40:00 -04:00
rgthree 8bbeb2b670 Add optimization for as well. 2023-09-18 17:20:28 -04:00
rgthree c4c2b8e9f1 Only connect to any when there's inputs. 2023-09-17 11:51:02 -04:00
rgthree 7a11b8cf28 Add an option to monitor for bad links. 2023-09-16 22:24:05 -04:00
rgthree 5f041918c3 Add back labels for other nodes 2023-09-16 22:18:24 -04:00
rgthree 3f63d2a4fb Don't show slot types on reroutes with labels. Fixes #25 2023-09-16 20:42:13 -04:00
Regis Gaughan, III 2faace6edd Merge pull request #22 from rgthree/dev
Fix some things in link fixer with complex flows.
2023-09-16 17:04:09 -04:00
rgthree 2f80a16124 Fix some things in link fixer with complex flows. Add "Fix in place" in workflow load check. 2023-09-16 16:59:26 -04:00
Regis Gaughan, III 006885dc51 Merge pull request #20 from rgthree/dev
Probably should consider this v1.1.0
2023-09-16 14:00:27 -04:00
rgthree 9d768d24f6 Ensure node collector passes title through. 2023-09-16 12:40:21 -04:00
rgthree 616b0242a6 Add a message for users to fix bad links when loading a new graph. 2023-09-16 00:46:05 -04:00
rgthree 332c5e795f README changes 2023-09-15 23:58:12 -04:00
rgthree 5bba13e43c New Feature: If a repeater is in a group without inputs, then toggle the entire group. 2023-09-15 23:51:28 -04:00
rgthree fe0dabddab Add cfg to context big, and change to float 2023-09-15 23:21:58 -04:00
rgthree a017fcccb9 Switch SDXL Config to KSampler Config, and add cfg 2023-09-15 23:04:01 -04:00
rgthree bfcfca57ea Links source file 2023-09-15 22:53:07 -04:00
rgthree f578e89a81 Add back DisplayInt until ComfyUI better supports porting nodes. 2023-09-15 22:52:50 -04:00
rgthree 2ad18864ce Stop loading js not meant for ComfyUI app (links) 2023-09-15 22:51:21 -04:00
rgthree 9e4754089d links: Only show button to fix when there's foxable results. 2023-09-15 22:23:25 -04:00
rgthree ab4783c277 Adds a link fixer html 2023-09-15 22:15:12 -04:00
rgthree 8d139bccad Don't track user config in git 2023-09-14 23:49:05 -04:00
rgthree 9e917321f0 Add default config file 2023-09-14 23:48:06 -04:00
rgthree 7258657b1e Add a config to stop the recursive optimization and add a message to the startup; this is incase ComfyUI updates and the optimizaiton no longer operates 2023-09-14 23:47:49 -04:00
rgthree ec3b3b9ce7 Override recursive execution.py methods in prestartup script. 2023-09-14 22:58:24 -04:00
rgthree 133a2756d2 Add resizing to reroute node. Fixes #17 2023-09-13 23:27:31 -04:00
rgthree 4d37d67373 Add ability to show a label/title. #17 2023-09-13 23:03:18 -04:00
rgthree 06581df8b6 Combine clone new node menu items (#17) 2023-09-13 22:12:49 -04:00
rgthree 47be4d4236 Allow reroutes to be resized. Seems to work when height is >= 30. Addresses part of #17 2023-09-13 22:06:52 -04:00
rgthree 51f26839f6 cleanup 2023-09-13 21:57:28 -04:00
rgthree 79b924c8c2 Add menu option to insert a reroute clone before/after a connected node. 2023-09-13 21:56:43 -04:00
rgthree 92160cf524 Only remove js directories if they exist. 2023-09-13 21:44:01 -04:00
rgthree 1d0f553315 Make context better match server inputs. 2023-09-12 00:02:05 -04:00
rgthree 9f1eaf46ed Pass prefix to log 2023-09-11 22:34:34 -04:00
rgthree 5d8ab65260 Fix bad quotes. 2023-09-11 22:33:13 -04:00
rgthree e95353130c Allow node replacement to port different slot names for inputs. 2023-09-11 22:32:53 -04:00
rgthree 12066db612 Remove Display Int and port to Display Any. 2023-09-11 22:31:43 -04:00
rgthree ef2054f262 Update all the awesome client node work here. Too much to mention. 2023-09-11 22:02:42 -04:00
rgthree 3cb4df567d New SDXL Power Prompt, Image Inset, Config, and more. 2023-09-11 21:56:03 -04:00
rgthree b8ec1d8556 Add new Big context and update context nodes for backwards compatibility. 2023-09-11 21:39:06 -04:00
rgthree 6ba369c4ce Add a base rgthree instance that can be globally accessed, and used to monitor meta keys. 2023-09-11 21:37:44 -04:00
rgthree a85e77e3f1 formatters 2023-09-11 21:24:09 -04:00
rgthree aef29a64a1 Some formatting 2023-09-11 21:23:43 -04:00
rgthree 2a77abd2db Add prettier, pylint, and yapf. Also, some typeings. 2023-09-11 21:08:26 -04:00
rgthree ceb78339f6 Add a simpler image inset cropper. 2023-09-07 13:13:55 -04:00
rgthree ace25204cc Fix 'Add Comfy Action' Sub Menu item 2023-09-06 23:54:55 -04:00
rgthree a698a335aa New Node: Fast Actions Button. A bunch of cleanup and code consolidation. 2023-09-06 23:33:14 -04:00
rgthree 48a556c4db Update missing typings. 2023-09-06 23:05:27 -04:00
rgthree b6f56fbd82 A few small tweaks; update typescript, stop tsc bugging on unused params, update collector constants; 2023-09-06 23:03:48 -04:00
282 changed files with 56116 additions and 5202 deletions
@@ -0,0 +1,20 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.COMFY_REGISTRY_ACCESS_TOKEN }}
+8 -1
View File
@@ -3,4 +3,11 @@ __pycache__
wildcards/**
.vscode/
.idea/
node_modules/
node_modules/
rgthree_config.json
web/rgthree_config.js
web/comfyui/rgthree_config.js
userdata/
userdata/**
web/comfyui/testing/
web/comfyui/tests/
+2
View File
@@ -0,0 +1,2 @@
shamefully-hoist=true
node-linker=hoisted
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 100,
"bracketSpacing": false,
"bracketSameLine": true
}
+633
View File
@@ -0,0 +1,633 @@
[MAIN]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
# in a server-like mode.
clear-cache-post-run=no
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# In error mode, messages with a category besides ERROR or FATAL are
# suppressed, and no reports are done by default. Error mode is compatible with
# disabling specific errors.
#errors-only=
# Always return a 0 (non-error) status code, even if lint errors are found.
# This is primarily useful in continuous integration scripts.
#exit-zero=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold under which the program will exit with error.
fail-under=10
# Interpret the stdin as a python script, whose filename needs to be passed as
# the module_or_package argument.
#from-stdin=
# Files or directories to be skipped. They should be base names, not paths.
ignore=CVS
# Add files or directories matching the regular expressions patterns to the
# ignore-list. The regex matches against paths and can be in Posix or Windows
# format. Because '\\' represents the directory delimiter on Windows systems,
# it can't be used as an escape character.
ignore-paths=
# Files or directories matching the regular expression patterns are skipped.
# The regex matches against base names, not paths. The default value ignores
# Emacs file locks
ignore-patterns=^\.#
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis). It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=1
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.10
# Discover python modules and packages in the file system subtree.
recursive=no
# Add paths to the list of the source roots. Supports globbing patterns. The
# source root is an absolute path or a path relative to the current working
# directory used to determine a package namespace for modules located under the
# source root.
source-roots=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type alias names. If left empty, type
# alias names will be checked with the set naming style.
#typealias-rgx=
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
asyncSetUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=builtins.BaseException,builtins.Exception
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow explicit reexports by alias from a package __init__.
allow-reexport-from-package=no
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to the given file (report RP0402 must not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
C0103, # invalid-name :: constant case
W0603 # global-statements
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member
[METHOD_ARGS]
# List of qualified names (i.e., library.method) which require a timeout
# parameter e.g. 'requests.api.get,requests.api.post'
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
#output-format=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=yes
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=4
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. No available dictionaries : You need to install
# both the python package and the system dependency for enchant to work..
spelling-dict=
# List of comma separated words that should be considered directives if they
# appear at the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+11
View File
@@ -0,0 +1,11 @@
# https://github.com/google/yapf
[style]
based_on_style = google
INDENT_WIDTH = 2
CONTINUATION_INDENT_WIDTH = 2
COLUMN_LIMIT = 100
DISABLE_ENDING_COMMA_HEURISTIC = true
DEDENT_CLOSING_BRACKETS = true
FORCE_MULTILINE_DICT = false
coalesce_brackets=True
ALLOW_SPLIT_BEFORE_DICT_VALUE = false
+290 -21
View File
@@ -1,12 +1,23 @@
# rgthree's Comfy Nodes
<h1 align="center">
rgthree-comfy
<br>
<sub><sup><i>Making ComfyUI more comfortable!</i></sup></sub>
<br>
</h1>
<p align="center">
<a href="#️-the-nodes">The Nodes</a> &nbsp; | &nbsp; <a href="#-improvements--features">Improvements & Features</a> &nbsp; | &nbsp; <a href="#-link-fixer">Link Fixer</a>
</p>
<hr>
A collection of nodes I've created while messing around with Stable Diffusion. I made them for myself to make my workflow easier and cleaner. You're welcome to try them out, but do so at your own risk. Since I made them for myself, I didn't battle test them much outside of my specific use cases.
A collection of nodes and improvements created while messing around with ComfyUI. I made them for myself to make my workflow cleaner, easier, and faster. You're welcome to try them out. But remember, I made them for my own use cases :)
![Context Node](./docs/rgthree_advanced.png)
# Install
# Get Started
1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI).
## Install
1. Install the great [ComfyUi](https://github.com/comfyanonymous/ComfyUI).
2. Clone this repo into `custom_modules`:
```
cd ComfyUI/custom_nodes
@@ -14,9 +25,19 @@ A collection of nodes I've created while messing around with Stable Diffusion. I
```
3. Start up ComfyUI.
## Settings
You can configure certain aspect of rgthree-comfy. For instance, perhaps a future ComfyUI change breaks rgthree-comfy, or you already have another extension that does something similar and you want to turn it off for rgthree-comfy.
# The Nodes
You can get to rgthree-settings by right-clicking on the empty part of the graph, and selecting `rgthree-comfy > Settings (rgthree-comfy)` or by clicking the `rgthree-comfy settings` in the ComfyUI settings dialog.
_(Note, settings are stored in an `rgthree_config.json` in the `rgthree-comfy` directory. There are other advanced settings that can only be configured there; You can copy default settings from `rgthree_config.json.default` before `rgthree_config.json` before modifying)_.
<br>
# ✴️ The Nodes
Note, you can right-click on a bunch of the rgthree-comfy nodes and select `🛟 Node Help` menu item for in-app help when available.
## Seed
> An intuitive seed control node for ComfyUI that works very much like Automatic1111's seed control.
@@ -39,25 +60,73 @@ A collection of nodes I've created while messing around with Stable Diffusion. I
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Use the right-click context menu to change the width, height and connection layout
> - Also toggle resizability (min size is 40x43 if resizing though), and title/type display.
>
> ![Router Node](./docs/rgthree_router.png)
> </details>
## Bookmark (🔖)
> Place the bookmark node anywhere on screen to quickly navigate to that with a shortcut key.
> <details>
> <summary>ℹ️ <i>See More Information</i></summary>
>
> - Define the `shortcut_key` to press to go right to that bookmark node, anchored in the top left.
> - You can also define the zoom level as well!
> - Pro tip: `shortcut_key` can be multiple keys. For instance "alt + shift + !" would require
> pressing the alt key, the shift key, and the "!" (as in the "1" key, but with shift pressed)
> in order to trigger.
> </details>
## Context
## Context / Context Big
> Pass along in general flow properties, and merge in new data. Similar to some other node suites "pipes" but easier merging, is more easily interoperable with standard nodes by both combining and exploding all in a single node.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Context and Context Big are backwards compatible with each other. That is, an input connected to a Context Big will be passed through the CONTEXT outputs through normal Context nodes and available as an output on either (or, Context Big if the output is only on that node, like "steps").
> - Pro Tip: When dragging a Context output over a nother node, hold down "ctrl" and release to automatically connect the other Context outputs to the hovered node.
> - Pro Tip: You can change between Context and Context Big nodes from the menu.
>
> ![Context Node](./docs/rgthree_context.png)
> </details>
## Display Int
> Nothing special, an 'output node' that displays an int _after execution_.
## Image Comparer
> The Image Comparer node compares two images on top of each other.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - **Note:** The right-click menu may show image options (Open Image, Save Image, etc.) which will correspond to the first image (image_a) if clicked on the left-half of the node, or the second image if on the right half of the node.
> - **Inputs:**
> - `image_a` _Required._ The first image to use to compare. If image_b is not supplied and image_a is a batch, the comparer will use the first two images of image_a.
> - `image_b` _Optional._ The second image to use to compare. Optional only if image_a is a batch with two images.
> - **Properties:** You can change the following properties (by right-clicking on the node, and select "Properties" or "Properties Panel" from the menu):
> - `comparer_mode` - Choose between "Slide" and "Click". Defaults to "Slide".
## Lora Loader Stack
## Image Inset Crop
> The node that lets you crop an input image by either pixel value, or percentage value.
## Display Any
> Displays most any piece of text data from the backend _after execution_.
## Power Lora Loader
> A super-simply Lora Loader node that can load multiple Loras at once, and quick toggle each, all in an ultra-condensed node.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Add as many Lora's as you would like by clicking the "+ Add Lora" button. There's no real limit!
> - Right-click on a Lora widget for special options to move the lora up or down
> _(no affect on image, just presentation)_, toggle it on/off, or delete the row all together.
> - from the properties, change the `Show Strengths` to choose between showing a single, simple
> strength value (which will be used for both model and clip), or a more advanced view with
> both model and clip strengths being modifiable.
> </details>
## ~~Lora Loader Stack~~
> _**Deprecated.** Used the `Power Lora Loader` instead._
>
> A simplified Lora Loader stack. Much like other suites, but more interoperable with standard inputs/outputs.
@@ -72,26 +141,101 @@ A collection of nodes I've created while messing around with Stable Diffusion. I
> needing a separate Lora Loaders
> </details>
## Power Prompt (Simple)
## Power Prompt - Simple
> Same as Power Prompt above, but without LORA support; made for a slightly cleaner negative prompt _(since negative prompts do not support loras)_.
## SDXL Power Prompt - Positive
> The SDXL sibling to the Power Prompt above. It contains the text_g and text_l as separate text inputs, as well a couple more input slots necessary to ensure proper clipe encoding. Combine with
## Context Switch
## SDXL Power Prompt - Simple
> Like the non-SDXL `Power Prompt - Simple` node, this one is essentially the same as the SDXL Power Prompt but without lora support for either non-lora positive prompts or SDXL negative prompts _(since negative prompts do not support loras)_.
## SDXL Config
> Just some configuration fields for SDXL prompting. Honestly, could be used for non SDXL too.
## Context Switch / Context Switch Big
> A powerful node to branch your workflow. Works by choosing the first Context input that is not null/empty.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Pass in several context nodes and the Context Switch will automatically choose the first non-null context to continue onward with.
> - Wondering how to toggle contexts to null? Use in conjuction with the **Fast Muter**
> - Wondering how to toggle contexts to null? Use in conjuction with the **Fast Muter** or **Fast Groups Muter**
>
> </details>
## Any Switch
> A powerful node to similar to the Context Switch above, that chooses the first input that is not null/empty.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Pass in several inmputs of the same type and the Any Switch will automatically choose the first non-null value to continue onward with.
> - Wondering how to toggle contexts to null? Use in conjuction with the **Fast Muter** or **Fast Groups Muter**
>
> </details>
## Fast Muter
> A powerful 'control panel' node to quickly toggle connected node allowing it to quickly be muted or enabled
## Power Primitive
> A single node that can output primitives (STRING, INT, FLOAT, BOOLEAN). If connecting an input, it will cast/convert the primitive input to the desired output.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - You can hide the type selector input from the right-click menu or properties.
> - You can also fast-switch the output type from the right-click menu as well.
>
> </details>
## Power Puter
> A powerful and versatile node that opens the door for a wide range of utility by offering mult-line code parsing for output. This node can be used for simple string concatenation, or math operations; to an image dimension or a node's widgets with advanced list comprehension. If you want to output something in your workflow, this is the node to do it.
>
> Additional documentation available in the [wiki](https://github.com/rgthree/rgthree-comfy/wiki/Node:-Power-Puter)
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Evaluate almost any kind of input and more, and choose your output from INT, FLOAT, STRING, or BOOLEAN.
> - Connect some nodes and do simply math operations like `a + b` or `ceil(1 / 2)`.
> - Or do more advanced things, like input an image, and get the width like `a.shape[2]`.
> - Even more powerful, you can target nodes in the prompt that's sent to the backend. For instance; if you have a Power Lora Loader node at id #5, and want to get a comma-delimited list of the enabled loras, you could enter:
>
> ```
> loras = [v.lora for v in node(5).inputs.values() if 'lora' in v and v.on]
> ', '.join(loras)
> ```
>
> </details>
## Fast Groups Muter
> The Fast Groups Muter is an input-less node that automatically collects all groups in your current workflow and allows you to quickly mute and unmute all nodes within the group.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Groups will automatically be shown, though you can filter, sort and more from the **node Properties** _(by right-clicking on the node, and select "Properties" or "Properties Panel" from the menu)_. Properties include:
> - `matchColors` - Only add groups that match the provided colors. Can be ComfyUI colors (red, pale_blue) or hex codes (#a4d399). Multiple can be added, comma delimited.
> - `matchTitle` - Filter the list of toggles by title match (string match, or regular expression).
> - `showNav` - Add / remove a quick navigation arrow to take you to the group. (default: true)
> - `showAllGraphs` - Show groups from all [sub]graphs in the workflow. (default: true)
> - `sort` - Sort the toggles' order by "alphanumeric", graph "position", or "custom alphabet". (default: "position")
> - `customSortAlphabet` - When the sort property is "custom alphabet" you can define the alphabet to use here, which will match the beginning of each group name and sort against it. If group titles do not match any custom alphabet entry, then they will be put after groups that do, ordered alphanumerically.
>
> This can be a list of single characters, like "zyxw..." or comma delimited strings for more control, like "sdxl,pro,sd,n,p".
>
> Note, when two group title match the same custom alphabet entry, the normal alphanumeric alphabet breaks the tie. For instance, a custom alphabet of "e,s,d" will order groups names like "SDXL, SEGS, Detailer" eventhough the custom alphabet has an "e" before "d" (where one may expect "SE" to be before "SD").
>
> To have "SEGS" appear before "SDXL" you can use longer strings. For instance, the custom alphabet value of "se,s,f" would work here.
> - `toggleRestriction` - Optionally, attempt to restrict the number of widgets that can be enabled to a maximum of one, or always one.
>
> _Note: If using "max one" or "always one" then this is only enforced when clicking a toggle on this node; if nodes within groups are changed outside of the initial toggle click, then these restriction will not be enforced, and could result in a state where more than one toggle is enabled. This could also happen if nodes are overlapped with multiple groups._
> </details>
## Fast Groups Bypasser
> _Same as **Fast Groups Muter** above, but sets the connected nodes to "Bypass" instead of "Mute"_
## Fast Muter
> A powerful 'control panel' node to quickly toggle connected nodes allowing them to quickly be muted or enabled
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Add a collection of all connected nodes allowing a single-spot as a "dashboard" to quickly enable and disable nodes. Two distinct nodes; one for "Muting" connected nodes, and one for "Bypassing" connected nodes.
> </details>
@@ -99,6 +243,16 @@ A collection of nodes I've created while messing around with Stable Diffusion. I
## Fast Bypasser
> Same as Fast Muter but sets the connected nodes to "Bypass"
## Fast Actions Button
> Oh boy, this node allows you to semi-automate connected nodes and/or ConfyUI.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Connect nodes and, at the least, mute, bypass or enable them when the button is pressed.
> - Certain nodes expose additional actions. For instance, the `Seed` node you can set `Randomize Each Time` or `Use Last Queued Seed` when the button is pressed.
> - Also, from the node properties, set a shortcut key to toggle the button actions, without needing a click!
> </details>
## Node Collector
> Used to cleanup noodles, this will accept any number of input nodes and passes it along to another node.
@@ -107,7 +261,7 @@ A collection of nodes I've created while messing around with Stable Diffusion. I
## Mute / Bypass Repeater
> A powerful node that will dispatch it's Mute/Bypass/Active mode to all connected input nodes.
> A powerful node that will dispatch its Mute/Bypass/Active mode to all connected input nodes or, if in a group w/o any connected inputs, will dispatch its Mute/Bypass/Active mode to all nodes in that group.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
@@ -118,13 +272,57 @@ A collection of nodes I've created while messing around with Stable Diffusion. I
## Mute / Bypass Relay
> An advanced node that, when working with a **Mute / Bypass Repeater** will relay a mute/bypass/activate signal to the repeater
> An advanced node that, when working with a **Mute / Bypass Repeater**, will relay its input nodes'
> modes (Mute, Bypass, or Active) to a connected repeater (which would then repeat that mode change
> to all of its inputs).
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
> - Useful when you want a specific node or set of nodes to be muted when a different set of nodes are also muted.
>
> - When all connected input nodes are muted, the relay will set a connected repeater to mute (by
> default).
> - When all connected input nodes are bypassed, the relay will set a connected repeater to
> bypass (by default).
> - When _any_ connected input nodes are active, the relay will set a connected repeater to
> active (by default).
> - **Note:** If no inputs are connected, the relay will set a connected repeater to its mode
> _when its own mode is changed_. **Note**, if any inputs are connected, then the above bullets
> will occur and the Relay's mode does not matter.
> - **Pro Tip:** You can change which signals get sent on the above in the `Properties`.
> For instance, you could configure an inverse relay which will send a MUTE when any of its
> inputs are active (instead of sending an ACTIVE signal), and send an ACTIVE signal when all
> of its inputs are muted (instead of sending a MUTE signal), etc.
> </details>
## Random Unmuter
> An advanced node used to unmute one of its inputs randomly when the graph is queued (and, immediately mute it back).
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - **Note:** All input nodes MUST be muted to start; if not this node will not randomly unmute another. (This is powerful, as the generated image can be dragged in and the chosen input will already by unmuted and work w/o any further action.)
> - **Tip:** Connect a Repeater's output to this nodes input and place that Repeater on a group without any other inputs, and it will mute/unmute the entire group.
> </details>
## Label
> A purely visual node, this allows you to add a floating label to your workflow.
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - The text shown is the "Title" of the node and you can adjust the the font size, font family,
> font color, text alignment as well as a background color, padding, background border
> radius, and angle (in degrees) from the node's properties. You can double-click the node to
> open the properties panel.
> - The Title also supports the literal sequence "\\n" to insert a newline when drawing the label.
> - ~**Pro Tip #1:** You can add multiline text from the properties panel _(because ComfyUI let's
> you shift + enter there, only)._~
> - **Pro Tip #2:** You can use ComfyUI's native "pin" option in the right-click menu to make the
> label stick to the workflow and clicks to "go through". You can right-click at any time to
> unpin.
> - **Pro Tip #3:** Color values are hexidecimal strings, like "#FFFFFF" for white, or "#660000"
> for dark red. You can supply a 7th & 8th value (or 5th if using shorthand) to create a
> transluscent color. For instance, "#FFFFFF88" is semi-transparent white.
> </details>
# Advanced Techniques
@@ -133,11 +331,11 @@ A collection of nodes I've created while messing around with Stable Diffusion. I
A lot of the power of these nodes comes from *Muting*. Muting is the basis of correctly implementing multiple paths for a workflow utlizing the Context Switch node.
While other extensions may provide switches, they often get it wrong causing your workflow to do extra work than is needed. While these switches may have a selector to choose which input to pass along, they don't stop execution of the other inputs, which will be wasted. Instead, Context Switch works by choosing the first non-empty context to pass along. Muting is one way to make a previous node empty, and causes no extra work to be done when set up correctly.
While other extensions may provide switches, they often get it wrong causing your workflow to do more work than is needed. While other switches may have a selector to choose which input to pass along, they don't stop the execution of the other inputs, which will result in wasted work. Instead, Context Switch works by choosing the first non-empty context to pass along and correctly Muting is one way to make a previous node empty, and causes no extra work to be done when set up correctly.
### To understand muting, is to understand the graph flow
Muting, and therefore using Switches, can often confuse people at first because it _feels_ muting a node, or using a switch, should be able to stop or direct the _forward_ flow of the graph. However, this is not the case and, in fact, the graph actually starts working backwards.
Muting, and therefore using Switches, can often confuse people at first because it _feels_ like muting a node, or using a switch, should be able to stop or direct the _forward_ flow of the graph. However, this is not the case and, in fact, the graph actually starts working backwards.
If you have a workflow that has a path like `... > Context > KSampler > VAE Decode > Save Image` it may initially _feel_ like you should be able to mute that first Context node and the graph would stop there when moving forward and skip the rest of that workflow.
@@ -163,3 +361,74 @@ Let's take a look at an example.
4. Lastly, and optionally, you can see the `Node Collector`. Use it to clean up noodles if you want and connect it to the muter. You can connect anything to it, but doing so may break your workflow's execution.
<br>
# ⚡ Improvements & Features
rgthree-comfy adds several improvements, features, and optimizations to ComfyUI that are not directly tied to nodes.
## Progress Bar
> A minimal progress bar that run alongs the top of the app window that shows the queue size, the current progress of the a prompt execution (within the same window), and the progress of multi-step nodes as well.
>
> <i>You can remove/enable from rgthree-comfy settings, as well as configure the height/size.</i>
## ~~ComfyUI Recursive Optimization~~
> 🎉 The newest version of ComfyUI no longer suffers from poor execution recursion! This feature
> has been removed from rgthree-comfy.
## "Queue Selected Output Nodes" in right-click menu
> Sometimes you want to just queue one or two paths to specific output node(s) without executing the entire workflow. Well, now you can do just that by right-clicking on an output node and selecting `Queue Selected Output Nodes (rgthree)`.
>
> <details>
> <summary>ℹ️ <i>More Information</i></summary>
>
> - Select the _output_ nodes you want to execute.
>
> - Note: Only output nodes are captured and traversed, not all selected nodes. So if you select an output AND a node from a different path, only the path connected to the output will be executed and not non-output nodes, even if they were selected.
>
> - Note: The whole workflow is serialized, and then we trim what we don't want for the backend. So things like all seed random/increment/decrement will run even if that node isn't being sent in the end, etc.
>
> </details>
## Auto-Nest Subdirectories in long Combos
> _(Off by default while experimenting, turn on in rgthree-comfy settings)_.
>
> Automatically detect top-level subdirectories in long combo lists (like, Load Checkpoint) and break out into sub directories.
## Quick Mute/Bypass Toggles in Group Headers
> _(Off by default while experimenting, turn on in rgthree-comfy settings)_.
>
> Adds a mute and/or bypass toggle icons in the top-right of Group Headers for one-click toggling of groups you may be currently looking at.
## Import Individual Node Widgets (Drag & Drop)
> _(Off by default while experimenting, turn on in rgthree-comfy settings)_.
>
> Allows dragging and dropping an image/JSON workflow from a previous generation and overriding the same node's widgets
> (that match with the same id & type). This is useful if you have several generations using the same general workflow
> and would like to import just some data, like a previous generation's seed, or prompt, etc.
## "Copy Image" in right-click menu
> Right clicking on a node that has an image should have a context-menu item of "Copy Image" will allow you to copy the image right to your clipboard
>
> <i>🎓 I believe this has graduated, with ComfyUI recently adding this setting too. You won't get two menu items; my code checks that there isn't already a "Copy Image" item there before adding it.</i>
## Other/Smaller Fixes
- Fixed the width of ultra-wide node chooser on double click.
- Fixed z-indexes for textareas that would overlap above other elements, like Properties Panel, or @pythongosssss's image viewer.
- Check for bad links when loading a workflow and log to console, by default. _(See Link Fixer below)._
<br>
# 📄 Link Fixer
If your workflows sometimes have missing connections, or even errors on load, start up ComfyUI and go to http://127.0.0.1:8188/rgthree/link_fixer which will allow you to drop in an image or workflow json file and check for and fix any bad links.
You can also enable a link fixer check in the rgthree-comfy settings to give you an alert if you load a workflow with bad linking data to start.
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
import subprocess
import os
from shutil import rmtree, copytree, ignore_patterns
from glob import glob
import time
import re
import argparse
from py.log import COLORS
from py.config import RGTHREE_CONFIG
step_msg = ''
step_start = 0
step_infos = []
def log_step(msg=None, status=None):
""" Logs a step keeping track of timing and initial msg. """
global step_msg # pylint: disable=W0601
global step_start # pylint: disable=W0601
global step_infos # pylint: disable=W0601
if msg:
tag = f'{COLORS["YELLOW"]}[ Notice ]' if status == 'Notice' else f'{COLORS["RESET"]}[Starting]'
step_msg = f'▻ {tag}{COLORS["RESET"]} {msg}...'
step_start = time.time()
step_infos = []
print(step_msg, end="\r")
elif status:
if status != 'Error':
warnings = [w for w in step_infos if w["type"] == 'warn']
status = "Warn" if warnings else status
step_time = round(time.time() - step_start, 3)
if status == 'Error':
status_msg = f'{COLORS["RED"]}⤫ {status}{COLORS["RESET"]}'
elif status == 'Warn':
status_msg = f'{COLORS["YELLOW"]}! {status}{COLORS["RESET"]}'
else:
status_msg = f'{COLORS["BRIGHT_GREEN"]}🗸 {status}{COLORS["RESET"]}'
print(f'{step_msg.ljust(64, ".")} {status_msg} ({step_time}s)')
for info in step_infos:
print(info["msg"])
def log_step_info(msg:str, status='info'):
global step_infos # pylint: disable=W0601
step_infos.append({"msg": f' - {msg}', "type": status})
def build(without_tests = True, fix = False):
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
DIR_SRC_WEB = os.path.abspath(f'{THIS_DIR}/src_web/')
DIR_WEB = os.path.abspath(f'{THIS_DIR}/web/')
DIR_WEB_COMFYUI = os.path.abspath(f'{DIR_WEB}/comfyui/')
if fix:
tss = glob(os.path.join(DIR_SRC_WEB, "**", "*.ts"), recursive=True)
log_step(msg=f'Fixing {len(tss)} ts files')
for ts in tss:
with open(ts, 'r', encoding="utf-8") as f:
content = f.read()
# (\s*from\s*['"](?!.*[.]js['"]).*?)(['"];) in vscode.
content, n = re.subn(r'(\s*from [\'"](?!.*[.]js[\'"]).*?)([\'"];)', '\\1.js\\2', content)
if n > 0:
filename = os.path.basename(ts)
log_step_info(
f'{filename} has {n} import{"s" if n > 1 else ""} that do not end in ".js"', 'warn')
with open(ts, 'w', encoding="utf-8") as f:
f.write(content)
log_step(status="Done")
log_step(msg='Copying web directory')
rmtree(DIR_WEB)
copytree(DIR_SRC_WEB, DIR_WEB, ignore=ignore_patterns("typings*", "*.ts", "*.scss"))
log_step(status="Done")
ts_version_result = subprocess.run(["node", "./node_modules/typescript/bin/tsc", "-v"],
capture_output=True,
text=True,
check=True)
ts_version = re.sub(r'^.*Version\s*([\d\.]+).*', 'v\\1', ts_version_result.stdout, flags=re.DOTALL)
log_step(msg=f'TypeScript ({ts_version})')
checked = subprocess.run(["node", "./node_modules/typescript/bin/tsc"], check=True)
log_step(status="Done")
if not without_tests:
log_step(msg='Removing directories (KEEPING TESTING)', status="Notice")
else:
log_step(msg='Removing unneeded directories')
test_path = os.path.join(DIR_WEB, 'comfyui', 'tests')
if os.path.exists(test_path):
rmtree(test_path)
rmtree(os.path.join(DIR_WEB, 'comfyui', 'testing'))
# Always remove the dummy scripts_comfy directory
rmtree(os.path.join(DIR_WEB, 'scripts_comfy'))
log_step(status="Done")
scsss = glob(os.path.join(DIR_SRC_WEB, "**", "*.scss"), recursive=True)
log_step(msg=f'SASS for {len(scsss)} files')
scsss = [i.replace(THIS_DIR, '.') for i in scsss]
cmds = ["node", "./node_modules/sass/sass"]
for scss in scsss:
out = scss.replace('src_web', 'web').replace('.scss', '.css')
cmds.append(f'{scss}:{out}')
cmds.append('--no-source-map')
checked = subprocess.run(cmds, check=True)
log_step(status="Done")
# Handle the common directories. Because ComfyUI loads under /extensions/rgthree-comfy we can't
# easily share sources outside of the `DIR_WEB_COMFYUI` _and_ allow typescript to resolve them in
# src view, so we set the path in the tsconfig to map an import of "rgthree/common" to the
# "src_web/common" directory, but then need to rewrite the comfyui JS files to load from
# "../../rgthree/common" (which we map correctly in rgthree_server.py).
log_step(msg='Cleaning Imports')
js_files = glob(os.path.join(DIR_WEB, '**', '*.js'), recursive=True)
for file in js_files:
rel_path = file.replace(f'{DIR_WEB}/', "")
with open(file, 'r', encoding="utf-8") as f:
filedata = f.read()
num = rel_path.count(os.sep)
if rel_path.startswith('comfyui'):
filedata = re.sub(r'(from\s+["\'])rgthree/', f'\\1{"../" * (num + 1)}rgthree/', filedata)
filedata = re.sub(r'(from\s+["\'])scripts/', f'\\1{"../" * (num + 1)}scripts/', filedata)
# Dynamic Imports
filedata = re.sub(r'(import\(["\'])rgthree/', f'\\1{"../" * (num + 1)}rgthree/', filedata)
else:
filedata = re.sub(r'(from\s+["\'])rgthree/', f'\\1{"../" * num}', filedata)
filedata = re.sub(r'(from\s+["\'])scripts/', f'\\1{"../" * (num + 1)}scripts/', filedata)
# Dynamic Imports
filedata = re.sub(r'(import\(["\'])rgthree/', f'\\1{"../" * num}', filedata)
filedata, n = re.subn(r'(\s*from [\'"](?!.*[.]js[\'"]).*?)([\'"];)', '\\1.js\\2', filedata)
if n > 0:
filename = file.split('rgthree-comfy')[1]
log_step_info(
f'{filename} has {n} import{"s" if n > 1 else ""} that do not end in ".js"', 'warn')
with open(file, 'w', encoding="utf-8") as f:
f.write(filedata)
log_step(status="Done")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("-t", "--no-tests", default=False, action="store_true")
parser.add_argument("-f", "--fix", default=False, action="store_true")
args = parser.parse_args()
start = time.time()
build(without_tests=args.no_tests, fix=args.fix)
print(f'Finished all in {round(time.time() - start, 3)}s')
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
import subprocess
import os
import re
import datetime
import time
import argparse
from __build__ import build, log_step, log_step_info
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
_FILE_PY_PROJECT = os.path.join(_THIS_DIR, 'pyproject.toml')
parser = argparse.ArgumentParser()
parser.add_argument(
"-m", "--message", help="The git commit message", required=True, action="store", type=str
)
args = parser.parse_args()
start = time.time()
build()
log_step(msg='Updating version in pyproject.toml')
py_project = ''
with open(_FILE_PY_PROJECT, "r", encoding='utf-8') as f:
py_project = f.read()
version = re.search(r'^\s*version\s*=\s*"(.*?)"', py_project, flags=re.MULTILINE)
version_old = version[1]
now = datetime.datetime.now()
version_new = version_old.split('.')
version_new[-1] = f'{str(now.year)[2:]}{now.month:02}{now.day:02}{now.hour:02}{now.minute:02}'
version_new = '.'.join(version_new)
log_step_info(f'Updating from v{version_old} to v{version_new}')
py_project = py_project.replace(version_old, version_new)
with open(_FILE_PY_PROJECT, "w", encoding='utf-8') as f:
f.write(py_project)
log_step(status="Done")
log_step('Running git add')
process = subprocess.Popen(['git', 'add', '.'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = process.communicate()
log_step(status="Done")
log_step('Running git commit')
process = subprocess.Popen(['git', 'commit', '-a', '-v', '-m', args.message],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout, stderr = process.communicate()
log_step(status="Done")
print(f'Finished all in {round(time.time() - start, 3)}s')
-15
View File
@@ -1,15 +0,0 @@
import subprocess
import os
import shutil
THIS_DIR=os.path.dirname(os.path.abspath(__file__))
DIR_DEV_JS=os.path.abspath(f'{THIS_DIR}/js')
DIR_WEB_JS=os.path.abspath(f'{THIS_DIR}/../../web/extensions/rgthree')
shutil.rmtree(DIR_DEV_JS)
subprocess.run(["tsc"])
shutil.rmtree(DIR_WEB_JS)
shutil.copytree(DIR_DEV_JS, DIR_WEB_JS, dirs_exist_ok=True)
+88 -28
View File
@@ -5,51 +5,111 @@
@description: A bunch of nodes I created that I also find useful.
"""
import glob
import inspect
from glob import glob
import json
import os
import shutil
import re
import random
from server import PromptServer
import execution
from .py.log import log, log_known_message
from .py.config import get_config_value
from .py.server.rgthree_server import *
from .py.log import log_welcome
from .py.context import RgthreeContext
from .py.context_switch import RgthreeContextSwitch
from .py.display_int import RgthreeDisplayInt
from .py.context_switch_big import RgthreeContextSwitchBig
from .py.display_any import RgthreeDisplayAny, RgthreeDisplayInt
from .py.lora_stack import RgthreeLoraLoaderStack
from .py.seed import RgthreeSeed
from .py.sdxl_empty_latent_image import RgthreeSDXLEmptyLatentImage
from .py.power_prompt import RgthreePowerPrompt
from .py.power_prompt_simple import RgthreePowerPromptSimple
from .py.image_inset_crop import RgthreeImageInsetCrop
from .py.context_big import RgthreeBigContext
from .py.dynamic_context import RgthreeDynamicContext
from .py.dynamic_context_switch import RgthreeDynamicContextSwitch
from .py.ksampler_config import RgthreeKSamplerConfig
from .py.sdxl_power_prompt_postive import RgthreeSDXLPowerPromptPositive
from .py.sdxl_power_prompt_simple import RgthreeSDXLPowerPromptSimple
from .py.any_switch import RgthreeAnySwitch
from .py.context_merge import RgthreeContextMerge
from .py.context_merge_big import RgthreeContextMergeBig
from .py.image_comparer import RgthreeImageComparer
from .py.power_lora_loader import RgthreePowerLoraLoader
from .py.power_primitive import RgthreePowerPrimitive
from .py.image_or_latent_size import RgthreeImageOrLatentSize
from .py.image_resize import RgthreeImageResize
from .py.power_puter import RgthreePowerPuter
NODE_CLASS_MAPPINGS = {
RgthreeContext.NAME: RgthreeContext,
RgthreeContextSwitch.NAME: RgthreeContextSwitch,
RgthreeDisplayInt.NAME: RgthreeDisplayInt,
RgthreeLoraLoaderStack.NAME: RgthreeLoraLoaderStack,
RgthreeSeed.NAME: RgthreeSeed,
RgthreeSDXLEmptyLatentImage.NAME: RgthreeSDXLEmptyLatentImage,
RgthreePowerPrompt.NAME: RgthreePowerPrompt,
RgthreePowerPromptSimple.NAME: RgthreePowerPromptSimple,
RgthreeBigContext.NAME: RgthreeBigContext,
RgthreeContext.NAME: RgthreeContext,
RgthreeContextSwitch.NAME: RgthreeContextSwitch,
RgthreeContextSwitchBig.NAME: RgthreeContextSwitchBig,
RgthreeContextMerge.NAME: RgthreeContextMerge,
RgthreeContextMergeBig.NAME: RgthreeContextMergeBig,
RgthreeDisplayInt.NAME: RgthreeDisplayInt,
RgthreeDisplayAny.NAME: RgthreeDisplayAny,
RgthreeLoraLoaderStack.NAME: RgthreeLoraLoaderStack,
RgthreeSeed.NAME: RgthreeSeed,
RgthreeImageInsetCrop.NAME: RgthreeImageInsetCrop,
RgthreePowerPrompt.NAME: RgthreePowerPrompt,
RgthreePowerPromptSimple.NAME: RgthreePowerPromptSimple,
RgthreeKSamplerConfig.NAME: RgthreeKSamplerConfig,
RgthreeSDXLEmptyLatentImage.NAME: RgthreeSDXLEmptyLatentImage,
RgthreeSDXLPowerPromptPositive.NAME: RgthreeSDXLPowerPromptPositive,
RgthreeSDXLPowerPromptSimple.NAME: RgthreeSDXLPowerPromptSimple,
RgthreeAnySwitch.NAME: RgthreeAnySwitch,
RgthreeImageComparer.NAME: RgthreeImageComparer,
RgthreePowerLoraLoader.NAME: RgthreePowerLoraLoader,
RgthreePowerPrimitive.NAME: RgthreePowerPrimitive,
RgthreeImageOrLatentSize.NAME: RgthreeImageOrLatentSize,
RgthreeImageResize.NAME: RgthreeImageResize,
RgthreePowerPuter.NAME: RgthreePowerPuter,
}
THIS_DIR=os.path.dirname(os.path.abspath(__file__))
DIR_DEV_JS=os.path.abspath(f'{THIS_DIR}/js')
DIR_PY=os.path.abspath(f'{THIS_DIR}/py')
DIR_WEB_JS=os.path.abspath(f'{THIS_DIR}/../../web/extensions/rgthree')
if not os.path.exists(DIR_WEB_JS):
os.makedirs(DIR_WEB_JS)
if get_config_value('unreleased.dynamic_context.enabled') is True:
NODE_CLASS_MAPPINGS[RgthreeDynamicContext.NAME] = RgthreeDynamicContext
NODE_CLASS_MAPPINGS[RgthreeDynamicContextSwitch.NAME] = RgthreeDynamicContextSwitch
shutil.copytree(DIR_DEV_JS, DIR_WEB_JS, dirs_exist_ok=True)
# WEB_DIRECTORY is the comfyui nodes directory that ComfyUI will link and auto-load.
WEB_DIRECTORY = "./web/comfyui"
nodes=[]
NOT_NODES=['constants','log','utils']
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
DIR_WEB = os.path.abspath(os.path.join(THIS_DIR, WEB_DIRECTORY))
DIR_PY = os.path.abspath(os.path.join(THIS_DIR, 'py'))
__all__ = ['NODE_CLASS_MAPPINGS']
# remove old directories
OLD_DIRS = [
os.path.abspath(os.path.join(THIS_DIR, '..', '..', 'web', 'extensions', 'rgthree')),
os.path.abspath(os.path.join(THIS_DIR, '..', '..', 'web', 'extensions', 'rgthree-comfy')),
]
for old_dir in OLD_DIRS:
if os.path.exists(old_dir):
shutil.rmtree(old_dir)
for file in glob.glob('*.py', root_dir=DIR_PY) + glob.glob('*.js', root_dir=DIR_DEV_JS):
name = os.path.splitext(file)[0]
if name not in nodes and name not in NOT_NODES and not name.startswith('_') and not name.startswith('base'):
nodes.append(name)
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']
log_welcome(num_nodes=len(nodes))
NOT_NODES = ['constants', 'log', 'utils', 'rgthree', 'rgthree_server', 'image_clipbaord', 'config']
nodes = []
for file in glob(os.path.join(DIR_PY, '*.py')) + glob(os.path.join(DIR_WEB, '*.js')):
name = os.path.splitext(os.path.basename(file))[0]
if name in NOT_NODES or name in nodes:
continue
if name.startswith('_') or name.startswith('base') or 'utils' in name:
continue
nodes.append(name)
if name == 'display_any':
nodes.append('display_int')
print()
adjs = ['exciting', 'extraordinary', 'epic', 'fantastic', 'magnificent']
log(f'Loaded {len(nodes)} {random.choice(adjs)} nodes. 🎉', color='BRIGHT_GREEN')
print()
if get_config_value('announcements.comfy-nodes-20.incompatible', True):
log_known_message('NODES2.0')
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
# A nicer output for git pulling custom nodes (and ComfyUI).
# Quick shell version: ls | xargs -I % sh -c 'echo; echo %; git -C % pull'
import os
import re
from subprocess import Popen, PIPE, STDOUT
from py.utils_userdata import read_userdata_json, save_userdata_json, delete_userdata_file
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
os.chdir(THIS_DIR)
os.chdir("../")
def pull_path(path, cache_key=None, origin=None, print_col_max=15, silent=False, cached_data=None):
if cached_data is None:
cached_data = {}
if cache_key is None:
cache_key = path
if not silent and path != '../':
print(f"🗀 {path:.<{print_col_max}}", end='')
if cache_key not in cached_data:
cached_data[cache_key] = {}
path_data = cached_data[cache_key]
output = ''
if 'head_branch' not in path_data:
p = Popen(["git", "-C", path, "remote", "show", 'origin'], stdout=PIPE, stderr=STDOUT)
output, _error = p.communicate()
match = re.search(r'HEAD branch:\s*([\S]*)', output.decode())
if not match:
return (False, output,)
path_data['head_branch'] = match.group(1)
origin = path_data['head_branch']
# Try the pull
args = ["git", "-C", path, "pull"]
if origin is not None:
args.append('origin')
args.append(origin)
output, _error = Popen(args, stdout=PIPE, stderr=STDOUT).communicate()
output = output.decode()
if 'Already up to date' in output:
if not silent:
print(' \33[32m🗸 Already up to date\33[0m')
return (True, output,)
if output.startswith('error:') and 'local changes to the following files' in output:
if not silent:
print(f' \33[31m🞫 Error: Needs Restore\33[0m \n {output}')
p = Popen(["git", "-C", path, "restore", "."], stdout=PIPE, stderr=STDOUT)
_output, _error = p.communicate()
success, i_output = pull_path(
path, origin=origin, print_col_max=print_col_max, silent=True, cached_data=cached_data
)
if not silent:
print(f" {'':.<{print_col_max}}", end='')
if success:
if not silent:
print(' \33[32m🗸 Updated\33[0m')
return (True, i_output,)
else:
if not silent:
print(f' \33[31m🞫 Unknown Error\33[0m \n {i_output}')
return (False, i_output,)
if not silent:
print(f' \33[33m🡅 Needs update.\33[0m \n {output}', end='')
return (False, output,)
# Get the list or custom nodes, so we can format the output a little more nicely.
custom_extensions = []
custom_extensions_name_max = 0
for directory in os.listdir(os.getcwd()):
if os.path.isdir(directory) and directory != "__pycache__": #and directory != "rgthree-comfy" :
custom_extensions.append({'directory': directory})
if len(directory) > custom_extensions_name_max:
custom_extensions_name_max = len(directory)
if len(custom_extensions) == 0:
custom_extensions_name_max = 15
else:
custom_extensions_name_max += 6
cached_data = read_userdata_json('update_comfy_dirs', {})
# Update ComfyUI itself.
print(f"{'Updating ComfyUI ':.<{custom_extensions_name_max}}", end='')
pull_path('../', cache_key='ComfyUI', origin='master', cached_data=cached_data)
# If we have custom nodes, update them as well.
if len(custom_extensions) > 0:
print(f'\nUpdating custom_nodes ({len(custom_extensions)}):')
for custom_extension in custom_extensions:
directory = custom_extension['directory']
if 'rgthree' in directory or directory.startswith('__'):
continue
pull_path(
directory,
cache_key=directory,
print_col_max=custom_extensions_name_max,
cached_data=cached_data
)
save_userdata_json('update_comfy_dirs', cached_data)
-24
View File
@@ -1,24 +0,0 @@
export class RgthreeBaseNode extends LGraphNode {
constructor(title = RgthreeBaseNode.title) {
super(title);
this.isVirtualNode = true;
if (title == '__NEED_NAME__') {
throw new Error('RgthreeBaseNode needs overrides.');
}
this.properties = this.properties || {};
}
onModeChange() {
}
set mode(mode) {
if (this.mode_ != mode) {
this.mode_ = mode;
this.onModeChange();
}
}
get mode() {
return this.mode_;
}
}
RgthreeBaseNode.title = "__NEED_NAME__";
RgthreeBaseNode.category = 'rgthree';
RgthreeBaseNode._category = 'rgthree';
-53
View File
@@ -1,53 +0,0 @@
import { app } from "../../scripts/app.js";
import { RgthreeBaseNode } from "./base_node.js";
export class BaseCollectorNode extends RgthreeBaseNode {
constructor(title) {
super(title);
this.isVirtualNode = true;
this.addInput("", "*");
this.addOutput("Output", "*");
}
clone() {
const cloned = super.clone();
return cloned;
}
updateOutputLinks(startNode = this) {
const type = startNode.constructor.type;
if (startNode.onConnectionsChainChange) {
startNode.onConnectionsChainChange();
}
if (startNode === this || (type === null || type === void 0 ? void 0 : type.includes('Reroute')) || (type === null || type === void 0 ? void 0 : type.includes('Combiner'))) {
for (const output of startNode.outputs) {
if (!output.links || !output.links.length)
continue;
for (const linkId of output.links) {
const link = app.graph.links[linkId];
if (!link)
continue;
const targetNode = app.graph.getNodeById(link.target_id);
targetNode && this.updateOutputLinks(targetNode);
}
}
}
}
onConnectionsChange(_type, _slotIndex, _isConnected, link_info, _ioSlot) {
if (!link_info)
return;
this.stabilizeInputsOutputs();
this.updateOutputLinks();
}
stabilizeInputsOutputs() {
var _a, _b;
for (let index = this.inputs.length - 1; index >= 0; index--) {
const input = this.inputs[index];
if (!input.link) {
this.removeInput(index);
}
}
this.addInput('', '*');
const outputLength = ((_b = (_a = this.outputs[0]) === null || _a === void 0 ? void 0 : _a.links) === null || _b === void 0 ? void 0 : _b.length) || 0;
if (outputLength > 1) {
this.outputs[0].links.length = 1;
}
}
}
-123
View File
@@ -1,123 +0,0 @@
import { app } from "../../scripts/app.js";
import { RgthreeBaseNode } from "./base_node.js";
import { addConnectionLayoutSupport, addMenuItem, getConnectedInputNodes, wait } from "./utils.js";
export class BaseNodeModeChanger extends RgthreeBaseNode {
constructor(title) {
super(title);
this.isVirtualNode = true;
this.debouncer = 0;
this.schedulePromise = null;
this.modeOn = -1;
this.modeOff = -1;
wait(10).then(() => {
if (this.modeOn < 0 || this.modeOff < 0) {
throw new Error('modeOn and modeOff must be overridden.');
}
});
this.addInput("", "*");
}
scheduleStabilizeWidgets() {
if (!this.schedulePromise) {
this.schedulePromise = new Promise((resolve) => {
setTimeout(() => {
resolve(this.stabilizeWidgets());
this.schedulePromise = null;
}, 100);
});
}
return this.schedulePromise;
}
stabilizeWidgets() {
if (!this.graph) {
return;
}
const linkedNodes = getConnectedInputNodes(app, this);
this.stabilizeInputsOutputs();
for (const [index, node] of linkedNodes.entries()) {
let widget = this.widgets && this.widgets[index];
if (!widget) {
this._tempWidth = this.size[0];
widget = this.addWidget('toggle', '', false, '', { "on": 'yes', "off": 'no' });
}
this.setWidget(widget, node);
}
if (this.widgets && this.widgets.length > linkedNodes.length) {
this._tempWidth = this.size[0];
this.widgets.length = linkedNodes.length;
}
app.graph.setDirtyCanvas(true, true);
setTimeout(() => { this.stabilizeWidgets(); }, 500);
}
setWidget(widget, linkedNode) {
const off = linkedNode.mode === this.modeOff;
widget.name = `Enable ${linkedNode.title}`;
widget.options = { 'on': 'yes', 'off': 'no' };
widget.value = !off;
widget.callback = () => {
const off = linkedNode.mode === this.modeOff;
linkedNode.mode = (off ? this.modeOn : this.modeOff);
widget.value = off;
};
}
onConnectionsChainChange() {
this.scheduleStabilizeWidgets();
}
onConnectionsChange(_type, _index, _connected, _linkInfo, _ioSlot) {
this.scheduleStabilizeWidgets();
}
removeInput(slot) {
this._tempWidth = this.size[0];
return super.removeInput(slot);
}
addInput(name, type, extra_info) {
this._tempWidth = this.size[0];
return super.addInput(name, type, extra_info);
}
stabilizeInputsOutputs() {
let hasEmptyInput = false;
for (let index = this.inputs.length - 1; index >= 0; index--) {
const input = this.inputs[index];
if (!input.link) {
if (index < this.inputs.length - 1) {
this.removeInput(index);
}
else {
hasEmptyInput = true;
}
}
}
!hasEmptyInput && this.addInput('', '*');
}
computeSize(out) {
var _a, _b;
let size = super.computeSize(out);
if (this._tempWidth) {
size[0] = this._tempWidth;
this._tempWidth = null;
}
if (this.properties['collapse_connections']) {
const rows = Math.max(((_a = this.inputs) === null || _a === void 0 ? void 0 : _a.length) || 0, ((_b = this.outputs) === null || _b === void 0 ? void 0 : _b.length) || 0, 1) - 1;
size[1] = size[1] - (rows * LiteGraph.NODE_SLOT_HEIGHT);
}
setTimeout(() => {
app.graph.setDirtyCanvas(true, true);
}, 16);
return size;
}
static setUp(clazz) {
addMenuItem(clazz, app, {
name: 'Refresh',
callback: (node) => { node.scheduleStabilizeWidgets(); }
});
addMenuItem(clazz, app, {
name: (node) => { var _a; return (`${((_a = node.properties) === null || _a === void 0 ? void 0 : _a['collapse_connections']) ? 'Show' : 'Collapse'} Connections`); },
property: 'collapse_connections',
prepareValue: (_value, node) => { var _a; return !((_a = node.properties) === null || _a === void 0 ? void 0 : _a['collapse_connections']); },
callback: (_node) => { app.graph.setDirtyCanvas(true, true); }
});
addConnectionLayoutSupport(clazz, app, [['Left'], ['Right']]);
LiteGraph.registerNodeType(clazz.type, clazz);
clazz.category = clazz._category;
}
}
BaseNodeModeChanger.collapsible = false;
-25
View File
@@ -1,25 +0,0 @@
import { app } from "../../scripts/app.js";
import { BaseNodeModeChanger } from "./base_node_mode_changer.js";
import { NodeTypesString } from "./constants.js";
const MODE_BYPASS = 4;
const MODE_ALWAYS = 0;
class BypasserNode extends BaseNodeModeChanger {
constructor(title = BypasserNode.title) {
super(title);
this.modeOn = MODE_ALWAYS;
this.modeOff = MODE_BYPASS;
}
}
BypasserNode.type = NodeTypesString.FAST_BYPASSER;
BypasserNode.title = NodeTypesString.FAST_BYPASSER;
app.registerExtension({
name: "rgthree.Bypasser",
registerCustomNodes() {
BypasserNode.setUp(BypasserNode);
},
loadedGraphNode(node) {
if (node.type == BypasserNode.title) {
node._tempWidth = node.size[0];
}
}
});
-12
View File
@@ -1,12 +0,0 @@
export function addRgthree(str) {
return str + ' (rgthree)';
}
export function stripRgthree(str) {
return str.replace(/\s*\(rgthree\)$/, '');
}
export const NodeTypesString = {
NODE_MODE_RELAY: addRgthree('Mute / Bypass Relay'),
NODE_MODE_REPEATER: addRgthree('Mute / Bypass Repeater'),
FAST_MUTER: addRgthree('Fast Muter'),
FAST_BYPASSER: addRgthree('Fast Bypasser'),
};
-10
View File
@@ -1,10 +0,0 @@
import { app } from "../../scripts/app.js";
import { addConnectionLayoutSupport } from "./utils.js";
app.registerExtension({
name: "rgthree.Context",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "Context (rgthree)") {
addConnectionLayoutSupport(nodeType, app, [['Left', 'Right'], ['Right', 'Left']]);
}
},
});
-25
View File
@@ -1,25 +0,0 @@
import { app } from "../../scripts/app.js";
import { BaseNodeModeChanger } from "./base_node_mode_changer.js";
import { NodeTypesString } from "./constants.js";
const MODE_MUTE = 2;
const MODE_ALWAYS = 0;
class MuterNode extends BaseNodeModeChanger {
constructor(title = MuterNode.title) {
super(title);
this.modeOn = MODE_ALWAYS;
this.modeOff = MODE_MUTE;
}
}
MuterNode.type = NodeTypesString.FAST_MUTER;
MuterNode.title = NodeTypesString.FAST_MUTER;
app.registerExtension({
name: "rgthree.Muter",
registerCustomNodes() {
MuterNode.setUp(MuterNode);
},
loadedGraphNode(node) {
if (node.type == MuterNode.title) {
node._tempWidth = node.size[0];
}
}
});
-86
View File
@@ -1,86 +0,0 @@
import { app } from "../../scripts/app.js";
import { addConnectionLayoutSupport, addHelp, getConnectedInputNodes, getConnectedOutputNodes, wait } from "./utils.js";
import { BaseCollectorNode } from './base_node_collector.js';
import { NodeTypesString, stripRgthree } from "./constants.js";
const MODE_ALWAYS = 0;
const MODE_MUTE = 2;
const MODE_BYPASS = 4;
const MODE_REPEATS = [MODE_MUTE, MODE_BYPASS];
class NodeModeRelay extends BaseCollectorNode {
constructor(title) {
super(title);
setTimeout(() => { this.stabilize(); }, 500);
this.removeOutput(0);
this.addOutput('REPEATER', '_NODE_REPEATER_', {
color_on: '#Fc0',
color_off: '#a80',
shape: LiteGraph.ARROW_SHAPE,
});
}
onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex) {
var _a, _b;
let canConnect = true;
if (super.onConnectOutput) {
canConnect = (_a = super.onConnectOutput) === null || _a === void 0 ? void 0 : _a.call(this, outputIndex, inputType, inputSlot, inputNode, inputIndex);
}
let nextNode = (_b = getConnectedOutputNodes(app, this, inputNode)[0]) !== null && _b !== void 0 ? _b : inputNode;
return canConnect && nextNode.type === NodeTypesString.NODE_MODE_REPEATER;
}
onConnectionsChange(type, slotIndex, isConnected, link_info, ioSlot) {
super.onConnectionsChange(type, slotIndex, isConnected, link_info, ioSlot);
setTimeout(() => { this.stabilize(); }, 500);
}
stabilize() {
var _a;
if (!this.graph || !this.isAnyOutputConnected() || !this.isInputConnected(0)) {
return;
}
const inputNodes = getConnectedInputNodes(app, this);
let mode = undefined;
for (const inputNode of inputNodes) {
if (mode === undefined) {
mode = inputNode.mode;
}
else if (mode === inputNode.mode && MODE_REPEATS.includes(mode)) {
continue;
}
else if (inputNode.mode === MODE_ALWAYS || mode === MODE_ALWAYS) {
mode = MODE_ALWAYS;
}
else {
mode = null;
}
}
if (mode != null) {
if ((_a = this.outputs) === null || _a === void 0 ? void 0 : _a.length) {
const outputNodes = getConnectedOutputNodes(app, this);
for (const outputNode of outputNodes) {
outputNode.mode = mode;
wait(16).then(() => {
outputNode.setDirtyCanvas(true, true);
});
}
}
}
setTimeout(() => { this.stabilize(); }, 500);
}
}
NodeModeRelay.type = NodeTypesString.NODE_MODE_RELAY;
NodeModeRelay.title = NodeTypesString.NODE_MODE_RELAY;
NodeModeRelay.help = [
`This node will relay its input nodes' modes (Mute, Bypass, or Active) to a connected`,
`${stripRgthree(NodeTypesString.NODE_MODE_REPEATER)} (which would then repeat that mode change to all of its inputs).`,
`\n`,
`\n- When all connected input nodes are muted, the relay will set a connected repeater to mute.`,
`\n- When all connected input nodes are bypassed, the relay will set a connected repeater to bypass.`,
`\n- When any connected input nodes are active, the relay will set a connected repeater to active.`,
].join(' ');
app.registerExtension({
name: "rgthree.NodeModeRepeaterHelper",
registerCustomNodes() {
addHelp(NodeModeRelay, app);
addConnectionLayoutSupport(NodeModeRelay, app, [['Left', 'Right'], ['Right', 'Left']]);
LiteGraph.registerNodeType(NodeModeRelay.type, NodeModeRelay);
NodeModeRelay.category = NodeModeRelay._category;
},
});
-111
View File
@@ -1,111 +0,0 @@
import { app } from "../../scripts/app.js";
import { BaseCollectorNode } from './base_node_collector.js';
import { NodeTypesString, stripRgthree } from "./constants.js";
import { addConnectionLayoutSupport, addHelp, getConnectedInputNodes, getConnectedOutputNodes } from "./utils.js";
class NodeModeRepeater extends BaseCollectorNode {
constructor(title) {
super(title);
this.hasRelayInput = false;
this.hasTogglerOutput = false;
this.removeOutput(0);
this.addOutput('FAST_TOGGLER', '_FAST_TOGGLER_', {
color_on: '#Fc0',
color_off: '#a80',
});
}
onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex) {
var _a;
let canConnect = !this.hasRelayInput;
if (super.onConnectOutput) {
canConnect = canConnect && ((_a = super.onConnectOutput) === null || _a === void 0 ? void 0 : _a.call(this, outputIndex, inputType, inputSlot, inputNode, inputIndex));
}
let nextNode = getConnectedOutputNodes(app, this, inputNode)[0] || inputNode;
return canConnect && (nextNode.type === NodeTypesString.FAST_MUTER || nextNode.type === NodeTypesString.FAST_BYPASSER);
}
onConnectInput(inputIndex, outputType, outputSlot, outputNode, outputIndex) {
var _a;
let canConnect = true;
if (super.onConnectInput) {
canConnect = canConnect && ((_a = super.onConnectInput) === null || _a === void 0 ? void 0 : _a.call(this, inputIndex, outputType, outputSlot, outputNode, outputIndex));
}
let nextNode = getConnectedOutputNodes(app, this, outputNode)[0] || outputNode;
const isNextNodeRelay = nextNode.type === NodeTypesString.NODE_MODE_RELAY;
return canConnect && (!isNextNodeRelay || !this.hasTogglerOutput);
}
onConnectionsChange(type, slotIndex, isConnected, linkInfo, ioSlot) {
super.onConnectionsChange(type, slotIndex, isConnected, linkInfo, ioSlot);
let hasTogglerOutput = false;
let hasRelayInput = false;
const outputNodes = getConnectedOutputNodes(app, this);
for (const outputNode of outputNodes) {
if ((outputNode === null || outputNode === void 0 ? void 0 : outputNode.type) === NodeTypesString.FAST_MUTER || (outputNode === null || outputNode === void 0 ? void 0 : outputNode.type) === NodeTypesString.FAST_BYPASSER) {
hasTogglerOutput = true;
break;
}
}
const inputNodes = getConnectedInputNodes(app, this);
for (const [index, inputNode] of inputNodes.entries()) {
if ((inputNode === null || inputNode === void 0 ? void 0 : inputNode.type) === NodeTypesString.NODE_MODE_RELAY) {
if (hasTogglerOutput) {
console.log(`Can't be connected to a Relay if also output to a toggler.`);
this.disconnectInput(index);
}
else {
hasRelayInput = true;
if (this.inputs[index]) {
this.inputs[index].color_on = '#FC0';
this.inputs[index].color_off = '#a80';
}
}
}
else {
inputNode.mode = this.mode;
}
}
this.hasTogglerOutput = hasTogglerOutput;
this.hasRelayInput = hasRelayInput;
if (this.hasRelayInput) {
if (this.outputs[0]) {
this.disconnectOutput(0);
this.removeOutput(0);
}
}
else if (!this.outputs[0]) {
this.addOutput('FAST_TOGGLER', '_FAST_TOGGLER_', {
color_on: '#Fc0',
color_off: '#a80',
});
}
}
onModeChange() {
super.onModeChange();
const linkedNodes = getConnectedInputNodes(app, this);
for (const node of linkedNodes) {
if (node.type !== NodeTypesString.NODE_MODE_RELAY) {
node.mode = this.mode;
}
}
}
}
NodeModeRepeater.type = NodeTypesString.NODE_MODE_REPEATER;
NodeModeRepeater.title = NodeTypesString.NODE_MODE_REPEATER;
NodeModeRepeater.help = [
`When this node's mode (Mute, Bypass, Active) changes, it will "repeat" that mode to all`,
`connected input nodes.`,
`\n`,
`\n- Optionally, connect this mode's output to a ${stripRgthree(NodeTypesString.FAST_MUTER)}`,
`or ${stripRgthree(NodeTypesString.FAST_BYPASSER)} for a single toggle to quickly`,
`mute/bypass all its connected nodes.`,
`\n- Optionally, connect a ${stripRgthree(NodeTypesString.NODE_MODE_RELAY)} to this nodes'`,
`inputs to have it automatically toggle its mode. If connected, this will always take`,
`precedence (and disconnect any connected fast togglers)`,
].join(' ');
app.registerExtension({
name: "rgthree.NodeModeRepeater",
registerCustomNodes() {
addHelp(NodeModeRepeater, app);
addConnectionLayoutSupport(NodeModeRepeater, app, [['Left', 'Right'], ['Right', 'Left']]);
LiteGraph.registerNodeType(NodeModeRepeater.type, NodeModeRepeater);
NodeModeRepeater.category = NodeModeRepeater._category;
},
});
-186
View File
@@ -1,186 +0,0 @@
import { app } from "../../scripts/app.js";
import { addConnectionLayoutSupport, addMenuSubMenu } from "./utils.js";
app.registerExtension({
name: "rgthree.Reroute",
registerCustomNodes() {
class RerouteNode extends LGraphNode {
constructor(title = RerouteNode.title) {
super(title);
this.isVirtualNode = true;
this.resizable = false;
this.size = RerouteNode.size;
this.addInput("", "*");
this.addOutput("", "*");
setTimeout(() => this.applyNodeSize(), 20);
}
configure(info) {
super.configure(info);
this.applyNodeSize();
}
clone() {
const cloned = super.clone();
cloned.inputs[0].type = '*';
cloned.outputs[0].type = '*';
return cloned;
}
onConnectionsChange(type, _slotIndex, connected, _link_info, _ioSlot) {
var _a, _b, _c;
if (connected && type === LiteGraph.OUTPUT) {
const types = new Set(this.outputs[0].links.map((l) => app.graph.links[l].type).filter((t) => t !== "*"));
if (types.size > 1) {
const linksToDisconnect = [];
for (let i = 0; i < this.outputs[0].links.length - 1; i++) {
const linkId = this.outputs[0].links[i];
const link = app.graph.links[linkId];
linksToDisconnect.push(link);
}
for (const link of linksToDisconnect) {
const node = app.graph.getNodeById(link.target_id);
node.disconnectInput(link.target_slot);
}
}
}
let currentNode = this;
let updateNodes = [];
let inputType = null;
let inputNode = null;
while (currentNode) {
updateNodes.unshift(currentNode);
const linkId = currentNode.inputs[0].link;
if (linkId !== null) {
const link = app.graph.links[linkId];
const node = app.graph.getNodeById(link.origin_id);
const type = node.constructor.type;
if (type === null || type === void 0 ? void 0 : type.includes("Reroute")) {
if (node === this) {
currentNode.disconnectInput(link.target_slot);
currentNode = null;
}
else {
currentNode = node;
}
}
else {
inputNode = currentNode;
inputType = (_b = (_a = node.outputs[link.origin_slot]) === null || _a === void 0 ? void 0 : _a.type) !== null && _b !== void 0 ? _b : null;
break;
}
}
else {
currentNode = null;
break;
}
}
const nodes = [this];
let outputType = null;
while (nodes.length) {
currentNode = nodes.pop();
const outputs = (currentNode.outputs ? currentNode.outputs[0].links : []) || [];
if (outputs.length) {
for (const linkId of outputs) {
const link = app.graph.links[linkId];
if (!link)
continue;
const node = app.graph.getNodeById(link.target_id);
if (!node)
continue;
const type = node.constructor.type;
if (type === null || type === void 0 ? void 0 : type.includes("Reroute")) {
nodes.push(node);
updateNodes.push(node);
}
else {
const nodeOutType = node.inputs && node.inputs[link === null || link === void 0 ? void 0 : link.target_slot] && node.inputs[link.target_slot].type ? node.inputs[link.target_slot].type : null;
if (inputType && nodeOutType !== inputType && nodeOutType !== '*') {
node.disconnectInput(link.target_slot);
}
else {
outputType = nodeOutType;
}
}
}
}
else {
}
}
const displayType = inputType || outputType || "*";
const color = LGraphCanvas.link_type_colors[displayType];
for (const node of updateNodes) {
node.outputs[0].type = inputType || "*";
node.__outputType = displayType;
node.outputs[0].name = node.properties.showOutputText ? displayType : "";
node.size = node.computeSize();
(_c = node.applyNodeSize) === null || _c === void 0 ? void 0 : _c.call(node);
for (const l of node.outputs[0].links || []) {
const link = app.graph.links[l];
if (link) {
link.color = color;
}
}
}
if (inputNode) {
const link = app.graph.links[inputNode.inputs[0].link];
if (link) {
link.color = color;
}
}
app.graph.setDirtyCanvas(true, true);
}
applyNodeSize() {
this.properties['size'] = this.properties['size'] || RerouteNode.size;
this.properties['size'] = [Number(this.properties['size'][0]), Number(this.properties['size'][1])];
this.size = this.properties['size'];
app.graph.setDirtyCanvas(true, true);
}
}
RerouteNode.title = "Reroute (rgthree)";
RerouteNode.category = 'rgthree';
RerouteNode._category = 'rgthree';
RerouteNode.title_mode = LiteGraph.NO_TITLE;
RerouteNode.collapsable = false;
RerouteNode.layout_slot_offset = 5;
RerouteNode.size = [40, 30];
addConnectionLayoutSupport(RerouteNode, app, [
["Left", "Right"],
["Left", "Top"],
["Left", "Bottom"],
["Right", "Left"],
["Right", "Top"],
["Right", "Bottom"],
["Top", "Left"],
["Top", "Right"],
["Top", "Bottom"],
["Bottom", "Left"],
["Bottom", "Right"],
["Bottom", "Top"],
], (node) => { node.applyNodeSize(); });
addMenuSubMenu(RerouteNode, app, {
name: 'Height',
property: 'size',
options: (() => {
const options = [];
for (let w = 8; w > 0; w--) {
options.push(`${w * 10}`);
}
return options;
})(),
prepareValue: (value, node) => [node.size[0], Number(value)],
callback: (node) => node.applyNodeSize()
});
addMenuSubMenu(RerouteNode, app, {
name: 'Width',
property: 'size',
options: (() => {
const options = [];
for (let w = 8; w > 0; w--) {
options.push(`${w * 10}`);
}
return options;
})(),
prepareValue: (value, node) => [Number(value), node.size[1]],
callback: (node) => node.applyNodeSize()
});
LiteGraph.registerNodeType(RerouteNode.title, RerouteNode);
RerouteNode.category = RerouteNode._category;
},
});
-132
View File
@@ -1,132 +0,0 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
const LAST_SEED_BUTTON_LABEL = '♻️ (Use Last Queued Seed)';
const SPECIAL_SEED_RANDOM = -1;
const SPECIAL_SEED_INCREMENT = -2;
const SPECIAL_SEED_DECREMENT = -3;
const SPECIAL_SEEDS = [SPECIAL_SEED_RANDOM, SPECIAL_SEED_INCREMENT, SPECIAL_SEED_DECREMENT];
class SeedControl {
constructor(node) {
this.lastSeed = undefined;
this.serializedCtx = {};
this.lastSeedValue = null;
this.node = node;
this.node.properties = this.node.properties || {};
for (const [i, w] of this.node.widgets.entries()) {
if (w.name === 'seed') {
this.seedWidget = w;
}
else if (w.name === 'control_after_generate') {
this.node.widgets.splice(i, 1);
}
}
if (!this.seedWidget) {
throw new Error('Something\'s wrong; expected seed widget');
}
const randMax = Math.min(1125899906842624, this.seedWidget.options.max);
const randMin = Math.max(0, this.seedWidget.options.min);
const randomRange = (randMax - Math.max(0, randMin)) / (this.seedWidget.options.step / 10);
this.node.addWidget('button', '🎲 Randomize Each Time', null, () => {
this.seedWidget.value = SPECIAL_SEED_RANDOM;
}, { serialize: false });
this.node.addWidget('button', '🎲 New Fixed Random', null, () => {
this.seedWidget.value = Math.floor(Math.random() * randomRange) * (this.seedWidget.options.step / 10) + randMin;
}, { serialize: false });
this.lastSeedButton = this.node.addWidget("button", LAST_SEED_BUTTON_LABEL, null, () => {
this.seedWidget.value = this.lastSeed;
this.lastSeedButton.name = LAST_SEED_BUTTON_LABEL;
this.lastSeedButton.disabled = true;
}, { width: 50, serialize: false });
this.lastSeedButton.disabled = true;
this.seedWidget.serializeValue = async (node, index) => {
const inputSeed = this.seedWidget.value;
this.serializedCtx = {
inputSeed: this.seedWidget.value,
};
if (SPECIAL_SEEDS.includes(this.serializedCtx.inputSeed)) {
if (typeof this.lastSeed === 'number' && !SPECIAL_SEEDS.includes(this.lastSeed)) {
if (inputSeed === SPECIAL_SEED_INCREMENT) {
this.serializedCtx.seedUsed = this.lastSeed + 1;
}
else if (inputSeed === SPECIAL_SEED_INCREMENT) {
this.serializedCtx.seedUsed = this.lastSeed - 1;
}
}
if (!this.serializedCtx.seedUsed || SPECIAL_SEEDS.includes(this.serializedCtx.seedUsed)) {
this.serializedCtx.seedUsed = Math.floor(Math.random() * randomRange) * (this.seedWidget.options.step / 10) + randMin;
}
}
else {
this.serializedCtx.seedUsed = this.seedWidget.value;
}
node.widgets_values[index] = this.serializedCtx.seedUsed;
this.seedWidget.value = this.serializedCtx.seedUsed;
this.lastSeed = this.serializedCtx.seedUsed;
if (SPECIAL_SEEDS.includes(this.serializedCtx.inputSeed)) {
this.lastSeedButton.name = `♻️ ${this.serializedCtx.seedUsed}`;
this.lastSeedButton.disabled = false;
if (this.lastSeedValue) {
this.lastSeedValue.value = `Last Seed: ${this.serializedCtx.seedUsed}`;
}
}
else {
this.lastSeedButton.name = LAST_SEED_BUTTON_LABEL;
this.lastSeedButton.disabled = true;
}
return this.serializedCtx.seedUsed;
};
this.seedWidget.afterQueued = () => {
if (this.serializedCtx.inputSeed) {
this.seedWidget.value = this.serializedCtx.inputSeed;
}
this.serializedCtx = {};
};
this.node.getExtraMenuOptions = (_, options) => {
options.splice(options.length - 1, 0, {
content: "Show/Hide Last Seed Value",
callback: (_value, _options, _event, _parentMenu, _node) => {
this.node.properties['showLastSeed'] = !this.node.properties['showLastSeed'];
if (this.node.properties['showLastSeed']) {
this.addLastSeedValue();
}
else {
this.removeLastSeedValue();
}
}
});
};
}
addLastSeedValue() {
if (this.lastSeedValue)
return;
this.lastSeedValue = ComfyWidgets["STRING"](this.node, "last_seed", ["STRING", { multiline: true }], app).widget;
this.lastSeedValue.inputEl.readOnly = true;
this.lastSeedValue.inputEl.style.fontSize = '0.75rem';
this.lastSeedValue.inputEl.style.textAlign = 'center';
this.lastSeedValue.serializeValue = async (node, index) => {
node.widgets_values[index] = '';
return '';
};
this.node.computeSize();
}
removeLastSeedValue() {
if (!this.lastSeedValue)
return;
this.lastSeedValue.inputEl.remove();
this.node.widgets.splice(this.node.widgets.indexOf(this.lastSeedValue), 1);
this.lastSeedValue = null;
this.node.computeSize();
}
}
app.registerExtension({
name: "rgthree.Seed",
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name === "Seed (rgthree)") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined;
this.seedControl = new SeedControl(this);
};
}
},
});
-304
View File
@@ -1,304 +0,0 @@
import { api } from '../../scripts/api.js';
const oldApiGetNodeDefs = api.getNodeDefs;
api.getNodeDefs = async function () {
const defs = await oldApiGetNodeDefs.call(api);
this.dispatchEvent(new CustomEvent('fresh-node-defs', { detail: defs }));
return defs;
};
var IoDirection;
(function (IoDirection) {
IoDirection[IoDirection["INPUT"] = 0] = "INPUT";
IoDirection[IoDirection["OUTPUT"] = 1] = "OUTPUT";
})(IoDirection || (IoDirection = {}));
const PADDING = 0;
export const LAYOUT_LABEL_TO_DATA = {
'Left': [LiteGraph.LEFT, [0, 0.5], [PADDING, 0]],
'Right': [LiteGraph.RIGHT, [1, 0.5], [-PADDING, 0]],
'Top': [LiteGraph.UP, [0.5, 0], [0, PADDING]],
'Bottom': [LiteGraph.DOWN, [0.5, 1], [0, -PADDING]],
};
const OPPOSITE_LABEL = {
'Left': 'Right',
'Right': 'Left',
'Top': 'Bottom',
'Bottom': 'Top',
};
export function addMenuItem(node, _app, config) {
const oldGetExtraMenuOptions = node.prototype.getExtraMenuOptions;
node.prototype.getExtraMenuOptions = function (canvas, menuOptions) {
oldGetExtraMenuOptions && oldGetExtraMenuOptions.apply(this, [canvas, menuOptions]);
const idx = menuOptions.findIndex(option => option === null || option === void 0 ? void 0 : option.content.includes('Shape')) + 1;
menuOptions.splice((idx > 0 ? idx : menuOptions.length - 1), 0, {
content: typeof config.name == 'function' ? config.name(this) : config.name,
callback: (_value, _options, _event, _parentMenu, _node) => {
if (config.property) {
this.properties = this.properties || {};
this.properties[config.property] = config.prepareValue ? config.prepareValue(this.properties[config.property], this) : !this.properties[config.property];
}
config.callback && config.callback(this);
}
});
};
}
export function addMenuSubMenu(node, _app, config) {
const oldGetExtraMenuOptions = node.prototype.getExtraMenuOptions;
node.prototype.getExtraMenuOptions = function (canvas, menuOptions) {
oldGetExtraMenuOptions && oldGetExtraMenuOptions.apply(this, [canvas, menuOptions]);
const idx = menuOptions.findIndex(option => option === null || option === void 0 ? void 0 : option.content.includes('Shape')) + 1;
menuOptions.splice((idx > 0 ? idx : menuOptions.length - 1), 0, {
content: typeof config.name == 'function' ? config.name(this) : config.name,
has_submenu: true,
callback: (_value, _options, event, parentMenu, _node) => {
new LiteGraph.ContextMenu(config.options.map(option => ({ content: option })), {
event,
parentMenu,
callback: (value, _options, _event, _parentMenu, _node) => {
if (config.property) {
this.properties = this.properties || {};
this.properties[config.property] = config.prepareValue ? config.prepareValue(value.content, this) : value.content;
}
config.callback && config.callback(this);
},
});
}
});
};
}
export function addConnectionLayoutSupport(node, app, options = [['Left', 'Right'], ['Right', 'Left']], callback) {
addMenuSubMenu(node, app, {
name: 'Connections Layout',
property: 'connections_layout',
options: options.map(option => option[0] + (option[1] ? ' -> ' + option[1] : '')),
prepareValue: (value, node) => {
var _a;
const values = value.split(' -> ');
if (!values[1] && !((_a = node.outputs) === null || _a === void 0 ? void 0 : _a.length)) {
values[1] = OPPOSITE_LABEL[values[0]];
}
if (!LAYOUT_LABEL_TO_DATA[values[0]] || !LAYOUT_LABEL_TO_DATA[values[1]]) {
throw new Error(`New Layout invalid: [${values[0]}, ${values[1]}]`);
}
return values;
},
callback: (node) => {
callback && callback(node);
app.graph.setDirtyCanvas(true, true);
},
});
node.prototype.getConnectionPos = function (isInput, slotNumber, out) {
return getConnectionPosForLayout(this, isInput, slotNumber, out);
};
}
export function setConnectionsLayout(node, newLayout = ['Left', 'Right']) {
var _a;
if (!newLayout[1] && !((_a = node.outputs) === null || _a === void 0 ? void 0 : _a.length)) {
newLayout[1] = OPPOSITE_LABEL[newLayout[0]];
}
if (!LAYOUT_LABEL_TO_DATA[newLayout[0]] || !LAYOUT_LABEL_TO_DATA[newLayout[1]]) {
throw new Error(`New Layout invalid: [${newLayout[0]}, ${newLayout[1]}]`);
}
node.properties = node.properties || {};
node.properties['connections_layout'] = newLayout;
}
export function setConnectionsCollapse(node, collapseConnections = null) {
node.properties = node.properties || {};
collapseConnections = collapseConnections !== null ? collapseConnections : !node.properties['collapse_connections'];
node.properties['collapse_connections'] = collapseConnections;
}
export function getConnectionPosForLayout(node, isInput, slotNumber, out) {
var _a, _b, _c;
out = out || new Float32Array(2);
node.properties = node.properties || {};
const layout = node.properties['connections_layout'] || ['Left', 'Right'];
const collapseConnections = node.properties['collapse_connections'] || false;
const offset = (_a = node.constructor.layout_slot_offset) !== null && _a !== void 0 ? _a : (LiteGraph.NODE_SLOT_HEIGHT * 0.5);
let side = isInput ? layout[0] : layout[1];
const otherSide = isInput ? layout[1] : layout[0];
const data = LAYOUT_LABEL_TO_DATA[side];
const slotList = node[isInput ? 'inputs' : 'outputs'];
const cxn = slotList[slotNumber];
if (!cxn) {
console.log('No connection found.. weird', isInput, slotNumber);
return out;
}
if (cxn.disabled) {
if (cxn.color_on !== '#666665') {
cxn._color_on_org = cxn._color_on_org || cxn.color_on;
cxn._color_off_org = cxn._color_off_org || cxn.color_off;
}
cxn.color_on = '#666665';
cxn.color_off = '#666665';
}
else if (cxn.color_on === '#666665') {
cxn.color_on = cxn._color_on_org || undefined;
cxn.color_off = cxn._color_off_org || undefined;
}
const displaySlot = collapseConnections ? 0 : (slotNumber - slotList.reduce((count, ioput, index) => {
count += index < slotNumber && ioput.hidden ? 1 : 0;
return count;
}, 0));
cxn.dir = data[0];
if (node.size[0] == 10 && ['Left', 'Right'].includes(side) && ['Top', 'Bottom'].includes(otherSide)) {
side = otherSide === 'Top' ? 'Bottom' : 'Top';
}
else if (node.size[1] == 10 && ['Top', 'Bottom'].includes(side) && ['Left', 'Right'].includes(otherSide)) {
side = otherSide === 'Left' ? 'Right' : 'Left';
}
if (side === 'Left') {
if (node.flags.collapsed) {
var w = node._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH;
out[0] = node.pos[0];
out[1] = node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT * 0.5;
}
else {
if (!isInput && !cxn.has_old_label) {
cxn.has_old_label = true;
cxn.old_label = cxn.label;
cxn.label = ' ';
}
else if (isInput && cxn.has_old_label) {
cxn.has_old_label = false;
cxn.label = cxn.old_label;
cxn.old_label = undefined;
}
out[0] = node.pos[0] + offset;
if ((_b = node.constructor) === null || _b === void 0 ? void 0 : _b.type.includes('Reroute')) {
out[1] = node.pos[1] + (node.size[1] * .5);
}
else {
out[1] =
node.pos[1] +
(displaySlot + 0.7) * LiteGraph.NODE_SLOT_HEIGHT +
(node.constructor.slot_start_y || 0);
}
}
}
else if (side === 'Right') {
if (node.flags.collapsed) {
var w = node._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH;
out[0] = node.pos[0] + w;
out[1] = node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT * 0.5;
}
else {
if (isInput && !cxn.has_old_label) {
cxn.has_old_label = true;
cxn.old_label = cxn.label;
cxn.label = ' ';
}
else if (!isInput && cxn.has_old_label) {
cxn.has_old_label = false;
cxn.label = cxn.old_label;
cxn.old_label = undefined;
}
out[0] = node.pos[0] + node.size[0] + 1 - offset;
if ((_c = node.constructor) === null || _c === void 0 ? void 0 : _c.type.includes('Reroute')) {
out[1] = node.pos[1] + (node.size[1] * .5);
}
else {
out[1] =
node.pos[1] +
(displaySlot + 0.7) * LiteGraph.NODE_SLOT_HEIGHT +
(node.constructor.slot_start_y || 0);
}
}
}
else if (side === 'Top') {
if (!cxn.has_old_label) {
cxn.has_old_label = true;
cxn.old_label = cxn.label;
cxn.label = ' ';
}
out[0] = node.pos[0] + (node.size[0] * .5);
out[1] = node.pos[1] + offset;
}
else if (side === 'Bottom') {
if (!cxn.has_old_label) {
cxn.has_old_label = true;
cxn.old_label = cxn.label;
cxn.label = ' ';
}
out[0] = node.pos[0] + (node.size[0] * .5);
out[1] = node.pos[1] + node.size[1] - offset;
}
return out;
}
export function wait(ms = 16, value) {
return new Promise((resolve) => {
setTimeout(() => { resolve(value); }, ms);
});
}
export function addHelp(node, app) {
const help = node.help;
if (help) {
addMenuItem(node, app, {
name: '🛟 Node Help',
property: 'help',
callback: (_node) => { alert(help); }
});
}
}
export function isPassThroughType(node) {
var _a;
const type = (_a = node === null || node === void 0 ? void 0 : node.constructor) === null || _a === void 0 ? void 0 : _a.type;
return (type === null || type === void 0 ? void 0 : type.includes('Reroute'))
|| (type === null || type === void 0 ? void 0 : type.includes('Node Combiner'))
|| (type === null || type === void 0 ? void 0 : type.includes('Node Collector'));
}
export function getConnectedInputNodes(app, startNode, currentNode) {
return getConnectedNodes(app, startNode, IoDirection.INPUT, currentNode);
}
export function getConnectedOutputNodes(app, startNode, currentNode) {
return getConnectedNodes(app, startNode, IoDirection.OUTPUT, currentNode);
}
function getConnectedNodes(app, startNode, dir = IoDirection.INPUT, currentNode) {
var _a, _b;
currentNode = currentNode || startNode;
let rootNodes = [];
const slotsToRemove = [];
if (startNode === currentNode || isPassThroughType(currentNode)) {
const removeDups = startNode === currentNode;
let linkIds;
if (dir == IoDirection.OUTPUT) {
linkIds = (_a = currentNode.outputs) === null || _a === void 0 ? void 0 : _a.flatMap(i => i.links);
}
else {
linkIds = (_b = currentNode.inputs) === null || _b === void 0 ? void 0 : _b.map(i => i.link);
}
let graph = app.graph;
for (const linkId of linkIds) {
const link = (linkId != null && graph.links[linkId]);
if (!link) {
continue;
}
const connectedId = dir == IoDirection.OUTPUT ? link.target_id : link.origin_id;
const originNode = graph.getNodeById(connectedId);
if (!link) {
console.error('No connected node found... weird');
continue;
}
if (isPassThroughType(originNode)) {
for (const foundNode of getConnectedNodes(app, startNode, dir, originNode)) {
if (!rootNodes.includes(foundNode)) {
rootNodes.push(foundNode);
}
}
}
else if (rootNodes.includes(originNode)) {
const connectedSlot = dir == IoDirection.OUTPUT ? link.origin_slot : link.target_slot;
removeDups && (slotsToRemove.push(connectedSlot));
}
else {
rootNodes.push(originNode);
}
}
for (const slot of slotsToRemove) {
if (dir == IoDirection.OUTPUT) {
startNode.disconnectOutput(slot);
}
else {
startNode.disconnectInput(slot);
}
}
}
return rootNodes;
}
+1094 -25
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -1,5 +1,12 @@
{
"devDependencies": {
"typescript": "^5.1.6"
}
}
{
"devDependencies": {
"prettier": "^3.9.9",
"typescript": "^7.0.2",
"sass": "^1.105.1",
"@comfyorg/comfyui-frontend-types": "1.48.3",
"web-tree-sitter": "^0.27.0"
},
"scripts": {
"build": "./__build__.py || python .\\__build__.py"
}
}
+384
View File
@@ -0,0 +1,384 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@comfyorg/comfyui-frontend-types':
specifier: 1.32.10
version: 1.32.10(vue@3.5.41(typescript@6.0.3))(zod@3.25.76)
prettier:
specifier: ^3.9.6
version: 3.9.6
sass:
specifier: 1.77.8
version: 1.77.8
typescript:
specifier: 6.0.3
version: 6.0.3
web-tree-sitter:
specifier: ^0.26.12
version: 0.26.12
packages:
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.8':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@comfyorg/comfyui-frontend-types@1.32.10':
resolution: {integrity: sha512-WfEJjv/sWo6lPNVgxThwPMZGGG0vO5vS+dSG65IKcr2/bpONCXv2NU0nVIw1VxFsbK2Lih+h3KOAXTrn/lltoA==}
peerDependencies:
vue: ^3.5.13
zod: ^3.23.8
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@vue/compiler-core@3.5.41':
resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==}
'@vue/compiler-dom@3.5.41':
resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==}
'@vue/compiler-sfc@3.5.41':
resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==}
'@vue/compiler-ssr@3.5.41':
resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==}
'@vue/reactivity@3.5.41':
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
'@vue/runtime-core@3.5.41':
resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==}
'@vue/runtime-dom@3.5.41':
resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==}
'@vue/server-renderer@3.5.41':
resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==}
'@vue/shared@3.5.41':
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
immutable@4.3.9:
resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
prettier@3.9.6:
resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
engines: {node: '>=14'}
hasBin: true
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
sass@1.77.8:
resolution: {integrity: sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==}
engines: {node: '>=14.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
vue@3.5.41:
resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
web-tree-sitter@0.26.12:
resolution: {integrity: sha512-fvqTNZQBGUgUgfP0mHw+iHf9Yf6bRQrp0A3pSf2v/hSKxkT1beCoIWoLVmlPL7O6dmySfSb/t1aJoJvrgTRStw==}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
snapshots:
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.8':
dependencies:
'@babel/types': 7.29.8
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@comfyorg/comfyui-frontend-types@1.32.10(vue@3.5.41(typescript@6.0.3))(zod@3.25.76)':
dependencies:
vue: 3.5.41(typescript@6.0.3)
zod: 3.25.76
'@jridgewell/sourcemap-codec@1.5.5': {}
'@vue/compiler-core@3.5.41':
dependencies:
'@babel/parser': 7.29.8
'@vue/shared': 3.5.41
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.41':
dependencies:
'@vue/compiler-core': 3.5.41
'@vue/shared': 3.5.41
'@vue/compiler-sfc@3.5.41':
dependencies:
'@babel/parser': 7.29.8
'@vue/compiler-core': 3.5.41
'@vue/compiler-dom': 3.5.41
'@vue/compiler-ssr': 3.5.41
'@vue/shared': 3.5.41
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.26
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.41':
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/reactivity@3.5.41':
dependencies:
'@vue/shared': 3.5.41
'@vue/runtime-core@3.5.41':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/shared': 3.5.41
'@vue/runtime-dom@3.5.41':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/runtime-core': 3.5.41
'@vue/shared': 3.5.41
csstype: 3.2.3
'@vue/server-renderer@3.5.41':
dependencies:
'@vue/compiler-ssr': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/shared@3.5.41': {}
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.2
binary-extensions@2.3.0: {}
braces@3.0.3:
dependencies:
fill-range: 7.1.1
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
csstype@3.2.3: {}
entities@7.0.1: {}
estree-walker@2.0.2: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
fsevents@2.3.3:
optional: true
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
immutable@4.3.9: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
nanoid@3.3.18: {}
normalize-path@3.0.0: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
postcss@8.5.26:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
prettier@3.9.6: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.2
sass@1.77.8:
dependencies:
chokidar: 3.6.0
immutable: 4.3.9
source-map-js: 1.2.1
source-map-js@1.2.1: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
typescript@6.0.3: {}
vue@3.5.41(typescript@6.0.3):
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/compiler-sfc': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/server-renderer': 3.5.41
'@vue/shared': 3.5.41
optionalDependencies:
typescript: 6.0.3
web-tree-sitter@0.26.12: {}
zod@3.25.76: {}
+3 -2
View File
@@ -1,4 +1,5 @@
import folder_paths
# Add 'saved_prompts' as a folder for Power Prompt node.
folder_paths.folder_names_and_paths['saved_prompts'] = ([], set(['.txt']))
# Removed Saved Prompts feature; No sure it worked any longer. UI should fail gracefully.
# TODO: See if anyone actually used this.
# folder_paths.folder_names_and_paths['saved_prompts'] = ([], set(['.txt']))
+38
View File
@@ -0,0 +1,38 @@
from .context_utils import is_context_empty
from .constants import get_category, get_name
from .utils import FlexibleOptionalInputType, any_type
def is_none(value):
"""Checks if a value is none. Pulled out in case we want to expand what 'None' means."""
if value is not None:
if isinstance(value, dict) and 'model' in value and 'clip' in value:
return is_context_empty(value)
return value is None
class RgthreeAnySwitch:
"""The dynamic Any Switch. """
NAME = get_name("Any Switch")
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = (any_type,)
RETURN_NAMES = ('*',)
FUNCTION = "switch"
def switch(self, **kwargs):
"""Chooses the first non-empty item to output."""
any_value = None
for key, value in kwargs.items():
if key.startswith('any_') and not is_none(value):
any_value = value
break
return (any_value,)
+111
View File
@@ -0,0 +1,111 @@
import os
import json
from .utils import get_dict_value, set_dict_value, dict_has_key, load_json_file
from .pyproject import VERSION
def get_config_value(key, default=None):
return get_dict_value(RGTHREE_CONFIG, key, default)
def extend_config(default_config, user_config):
""" Returns a new config dict combining user_config into defined keys for default_config."""
cfg = {}
for key, value in default_config.items():
if key not in user_config:
cfg[key] = value
elif isinstance(value, dict):
cfg[key] = extend_config(value, user_config[key])
else:
cfg[key] = user_config[key] if key in user_config else value
return cfg
def set_user_config(data: dict):
""" Sets the user configuration."""
count = 0
for key, value in data.items():
if dict_has_key(DEFAULT_CONFIG, key):
set_dict_value(USER_CONFIG, key, value)
set_dict_value(RGTHREE_CONFIG, key, value)
count += 1
if count > 0:
write_user_config()
def get_rgthree_default_config():
""" Gets the default configuration."""
return load_json_file(DEFAULT_CONFIG_FILE, default={})
def get_rgthree_user_config():
""" Gets the user configuration."""
return load_json_file(USER_CONFIG_FILE, default={})
def write_user_config():
""" Writes the user configuration."""
with open(USER_CONFIG_FILE, 'w+', encoding='UTF-8') as file:
json.dump(USER_CONFIG, file, sort_keys=True, indent=2, separators=(",", ": "))
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_CONFIG_FILE = os.path.join(THIS_DIR, '..', 'rgthree_config.json.default')
USER_CONFIG_FILE = os.path.join(THIS_DIR, '..', 'rgthree_config.json')
DEFAULT_CONFIG = {}
USER_CONFIG = {}
RGTHREE_CONFIG = {}
def refresh_config():
"""Refreshes the config."""
global DEFAULT_CONFIG, USER_CONFIG, RGTHREE_CONFIG
DEFAULT_CONFIG = get_rgthree_default_config()
USER_CONFIG = get_rgthree_user_config()
# Migrate old config options into "features"
needs_to_write_user_config = False
if 'patch_recursive_execution' in USER_CONFIG:
del USER_CONFIG['patch_recursive_execution']
needs_to_write_user_config = True
if 'features' in USER_CONFIG and 'patch_recursive_execution' in USER_CONFIG['features']:
del USER_CONFIG['features']['patch_recursive_execution']
needs_to_write_user_config = True
if 'show_alerts_for_corrupt_workflows' in USER_CONFIG:
if 'features' not in USER_CONFIG:
USER_CONFIG['features'] = {}
USER_CONFIG['features']['show_alerts_for_corrupt_workflows'] = USER_CONFIG[
'show_alerts_for_corrupt_workflows']
del USER_CONFIG['show_alerts_for_corrupt_workflows']
needs_to_write_user_config = True
if 'monitor_for_corrupt_links' in USER_CONFIG:
if 'features' not in USER_CONFIG:
USER_CONFIG['features'] = {}
USER_CONFIG['features']['monitor_for_corrupt_links'] = USER_CONFIG['monitor_for_corrupt_links']
del USER_CONFIG['monitor_for_corrupt_links']
needs_to_write_user_config = True
if needs_to_write_user_config is True:
print('writing new user config.')
write_user_config()
RGTHREE_CONFIG = {"version": VERSION} | extend_config(DEFAULT_CONFIG, USER_CONFIG)
if "unreleased" in USER_CONFIG and "unreleased" not in RGTHREE_CONFIG:
RGTHREE_CONFIG["unreleased"] = USER_CONFIG["unreleased"]
if "debug" in USER_CONFIG and "debug" not in RGTHREE_CONFIG:
RGTHREE_CONFIG["debug"] = USER_CONFIG["debug"]
def get_config():
"""Returns the congfig."""
return RGTHREE_CONFIG
refresh_config()
+24 -49
View File
@@ -1,58 +1,33 @@
"""The Context node."""
from .context_utils import (ORIG_CTX_OPTIONAL_INPUTS, ORIG_CTX_RETURN_NAMES, ORIG_CTX_RETURN_TYPES,
get_orig_context_return_tuple, new_context)
from .constants import get_category, get_name
ctx_keys = ["model", "clip", "vae", "positive", "negative", "latent", "images", "seed"]
def new_context(context=None, model=None, clip=None, vae=None, positive=None, negative=None, latent=None, images=None, seed=None):
ctx = {}
for key in ctx_keys:
v = None
v = v if v != None else model if key == 'model' else None
v = v if v != None else clip if key == 'clip' else None
v = v if v != None else vae if key == 'vae' else None
v = v if v != None else positive if key == 'positive' else None
v = v if v != None else negative if key == 'negative' else None
v = v if v != None else latent if key == 'latent' else None
v = v if v != None else images if key == 'images' else None
v = v if v != None else seed if key == 'seed' else None
ctx[key] = a_b(v, d_k(context, key))
return ctx
def d_k(dct, key, default=None):
return dct[key] if dct != None and key in dct else default
def a_b(a, b):
return a if a != None else b
class RgthreeContext:
"""The initial Context node.
NAME = get_name('Context')
CATEGORY = get_category()
For now, this nodes' outputs will remain as-is, as they are perfect for most 1.5 application, but
is also backwards compatible with other Context nodes.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {},
"optional": {
"base_ctx": ("RGTHREE_CONTEXT",),
"model": ("MODEL",),
"clip": ("CLIP",),
"vae": ("VAE",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"latent": ("LATENT",),
"images": ("IMAGE", ),
"seed": ("INT", {"forceInput": True}),
},
"hidden": {
"prompt": "PROMPT",
},
}
RETURN_TYPES = ("RGTHREE_CONTEXT", "MODEL", "CLIP", "VAE", "CONDITIONING", "CONDITIONING", "LATENT", "IMAGE", "INT",)
RETURN_NAMES = ("CONTEXT", "MODEL", "CLIP", "VAE", "POSITIVE", "NEGATIVE", "LATENT", "IMAGE", "SEED",)
FUNCTION = "convert"
NAME = get_name("Context")
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": ORIG_CTX_OPTIONAL_INPUTS,
"hidden": {
"version": "FLOAT"
},
}
def convert(self, base_ctx=None, model=None, clip=None, vae=None, positive=None, negative=None, latent=None, images=None, seed=None, prompt=None):
ctx = new_context(context=base_ctx, model=model, clip=clip, vae=vae, positive=positive, negative=negative, latent=latent, images=images, seed=seed)
return (ctx, ctx['model'], ctx['clip'], ctx['vae'], ctx['positive'], ctx['negative'], ctx['latent'], ctx['images'], ctx['seed'],)
RETURN_TYPES = ORIG_CTX_RETURN_TYPES
RETURN_NAMES = ORIG_CTX_RETURN_NAMES
FUNCTION = "convert"
def convert(self, base_ctx=None, **kwargs): # pylint: disable = missing-function-docstring
ctx = new_context(base_ctx, **kwargs)
return get_orig_context_return_tuple(ctx)
+31
View File
@@ -0,0 +1,31 @@
"""The Conmtext big node."""
from .constants import get_category, get_name
from .context_utils import (ALL_CTX_OPTIONAL_INPUTS, ALL_CTX_RETURN_NAMES, ALL_CTX_RETURN_TYPES,
new_context, get_context_return_tuple)
class RgthreeBigContext:
"""The Context Big node.
This context node will expose all context fields as inputs and outputs. It is backwards compatible
with other context nodes and can be intertwined with them.
"""
NAME = get_name("Context Big")
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name,missing-function-docstring
return {
"required": {},
"optional": ALL_CTX_OPTIONAL_INPUTS,
"hidden": {},
}
RETURN_TYPES = ALL_CTX_RETURN_TYPES
RETURN_NAMES = ALL_CTX_RETURN_NAMES
FUNCTION = "convert"
def convert(self, base_ctx=None, **kwargs): # pylint: disable = missing-function-docstring
ctx = new_context(base_ctx, **kwargs)
return get_context_return_tuple(ctx)
+37
View File
@@ -0,0 +1,37 @@
"""The Context Switch (Big)."""
from .constants import get_category, get_name
from .context_utils import (ORIG_CTX_RETURN_TYPES, ORIG_CTX_RETURN_NAMES, merge_new_context,
get_orig_context_return_tuple, is_context_empty)
from .utils import FlexibleOptionalInputType
class RgthreeContextMerge:
"""The Context Merge node."""
NAME = get_name("Context Merge")
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": FlexibleOptionalInputType("RGTHREE_CONTEXT"),
}
RETURN_TYPES = ORIG_CTX_RETURN_TYPES
RETURN_NAMES = ORIG_CTX_RETURN_NAMES
FUNCTION = "merge"
def get_return_tuple(self, ctx):
"""Returns the context data. Separated so it can be overridden."""
return get_orig_context_return_tuple(ctx)
def merge(self, **kwargs):
"""Merges any non-null passed contexts; later ones overriding earlier."""
ctxs = [
value for key, value in kwargs.items()
if key.startswith('ctx_') and not is_context_empty(value)
]
ctx = merge_new_context(*ctxs)
return self.get_return_tuple(ctx)
+16
View File
@@ -0,0 +1,16 @@
"""The Context Switch (Big)."""
from .constants import get_category, get_name
from .context_utils import (ALL_CTX_RETURN_TYPES, ALL_CTX_RETURN_NAMES, get_context_return_tuple)
from .context_merge import RgthreeContextMerge
class RgthreeContextMergeBig(RgthreeContextMerge):
"""The Context Merge Big node."""
NAME = get_name("Context Merge Big")
RETURN_TYPES = ALL_CTX_RETURN_TYPES
RETURN_NAMES = ALL_CTX_RETURN_NAMES
def get_return_tuple(self, ctx):
"""Returns the context data. Separated so it can be overridden."""
return get_context_return_tuple(ctx)
+27 -36
View File
@@ -1,45 +1,36 @@
"""The original Context Switch."""
from .constants import get_category, get_name
from .context_utils import (ORIG_CTX_RETURN_TYPES, ORIG_CTX_RETURN_NAMES, is_context_empty,
get_orig_context_return_tuple)
from .utils import FlexibleOptionalInputType
def is_context_empty(ctx):
if ctx == None:
return True
return all(v == None for v in ctx.values())
class RgthreeContextSwitch:
"""The (original) Context Switch node."""
NAME = get_name('Context Switch')
CATEGORY = get_category()
NAME = get_name("Context Switch")
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(s):
return {
"required": {},
"optional": {
"ctx_01": ("RGTHREE_CONTEXT",),
"ctx_02": ("RGTHREE_CONTEXT",),
"ctx_03": ("RGTHREE_CONTEXT",),
"ctx_04": ("RGTHREE_CONTEXT",),
},
"hidden": {
"prompt": "PROMPT",
},
}
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": FlexibleOptionalInputType("RGTHREE_CONTEXT"),
}
RETURN_TYPES = ("RGTHREE_CONTEXT", "MODEL", "CLIP", "VAE", "CONDITIONING", "CONDITIONING", "LATENT", "IMAGE", "INT",)
RETURN_NAMES = ("CONTEXT", "MODEL", "CLIP", "VAE", "POSITIVE", "NEGATIVE", "LATENT", "IMAGE", "SEED",)
FUNCTION = "switch"
RETURN_TYPES = ORIG_CTX_RETURN_TYPES
RETURN_NAMES = ORIG_CTX_RETURN_NAMES
FUNCTION = "switch"
def switch(self, ctx_01=None, ctx_02=None, ctx_03=None, ctx_04=None, prompt=None):
ctx=None
if not is_context_empty(ctx_01):
ctx = ctx_01
elif not is_context_empty(ctx_02):
ctx = ctx_02
elif not is_context_empty(ctx_03):
ctx = ctx_03
elif not is_context_empty(ctx_04):
ctx = ctx_04
if ctx != None:
return (ctx, ctx['model'], ctx['clip'], ctx['vae'], ctx['positive'], ctx['negative'], ctx['latent'], ctx['images'], ctx['seed'],)
return (None,None,None,None,None,None,None,None,None,)
def get_return_tuple(self, ctx):
"""Returns the context data. Separated so it can be overridden."""
return get_orig_context_return_tuple(ctx)
def switch(self, **kwargs):
"""Chooses the first non-empty Context to output."""
ctx = None
for key, value in kwargs.items():
if key.startswith('ctx_') and not is_context_empty(value):
ctx = value
break
return self.get_return_tuple(ctx)
+16
View File
@@ -0,0 +1,16 @@
"""The Context Switch (Big)."""
from .constants import get_category, get_name
from .context_utils import (ALL_CTX_RETURN_TYPES, ALL_CTX_RETURN_NAMES, get_context_return_tuple)
from .context_switch import RgthreeContextSwitch
class RgthreeContextSwitchBig(RgthreeContextSwitch):
"""The Context Switch Big node."""
NAME = get_name("Context Switch Big")
RETURN_TYPES = ALL_CTX_RETURN_TYPES
RETURN_NAMES = ALL_CTX_RETURN_NAMES
def get_return_tuple(self, ctx):
"""Overrides the RgthreeContextSwitch `get_return_tuple` to return big context data."""
return get_context_return_tuple(ctx)
+118
View File
@@ -0,0 +1,118 @@
"""A set of constants and utilities for handling contexts.
Sets up the inputs and outputs for the Context going forward, with additional functions for
creating and exporting context objects.
"""
import comfy.samplers
import folder_paths
_all_context_input_output_data = {
"base_ctx": ("base_ctx", "RGTHREE_CONTEXT", "CONTEXT"),
"model": ("model", "MODEL", "MODEL"),
"clip": ("clip", "CLIP", "CLIP"),
"vae": ("vae", "VAE", "VAE"),
"positive": ("positive", "CONDITIONING", "POSITIVE"),
"negative": ("negative", "CONDITIONING", "NEGATIVE"),
"latent": ("latent", "LATENT", "LATENT"),
"images": ("images", "IMAGE", "IMAGE"),
"seed": ("seed", "INT", "SEED"),
"steps": ("steps", "INT", "STEPS"),
"step_refiner": ("step_refiner", "INT", "STEP_REFINER"),
"cfg": ("cfg", "FLOAT", "CFG"),
"ckpt_name": ("ckpt_name", folder_paths.get_filename_list("checkpoints"), "CKPT_NAME"),
"sampler": ("sampler", comfy.samplers.KSampler.SAMPLERS, "SAMPLER"),
"scheduler": ("scheduler", comfy.samplers.KSampler.SCHEDULERS, "SCHEDULER"),
"clip_width": ("clip_width", "INT", "CLIP_WIDTH"),
"clip_height": ("clip_height", "INT", "CLIP_HEIGHT"),
"text_pos_g": ("text_pos_g", "STRING", "TEXT_POS_G"),
"text_pos_l": ("text_pos_l", "STRING", "TEXT_POS_L"),
"text_neg_g": ("text_neg_g", "STRING", "TEXT_NEG_G"),
"text_neg_l": ("text_neg_l", "STRING", "TEXT_NEG_L"),
"mask": ("mask", "MASK", "MASK"),
"control_net": ("control_net", "CONTROL_NET", "CONTROL_NET"),
}
force_input_types = ["INT", "STRING", "FLOAT"]
force_input_names = ["sampler", "scheduler", "ckpt_name"]
def _create_context_data(input_list=None):
"""Returns a tuple of context inputs, return types, and return names to use in a node"s def"""
if input_list is None:
input_list = _all_context_input_output_data.keys()
list_ctx_return_types = []
list_ctx_return_names = []
ctx_optional_inputs = {}
for inp in input_list:
data = _all_context_input_output_data[inp]
list_ctx_return_types.append(data[1])
list_ctx_return_names.append(data[2])
ctx_optional_inputs[data[0]] = tuple([data[1]] + ([{
"forceInput": True
}] if data[1] in force_input_types or data[0] in force_input_names else []))
ctx_return_types = tuple(list_ctx_return_types)
ctx_return_names = tuple(list_ctx_return_names)
return (ctx_optional_inputs, ctx_return_types, ctx_return_names)
ALL_CTX_OPTIONAL_INPUTS, ALL_CTX_RETURN_TYPES, ALL_CTX_RETURN_NAMES = _create_context_data()
_original_ctx_inputs_list = [
"base_ctx", "model", "clip", "vae", "positive", "negative", "latent", "images", "seed"
]
ORIG_CTX_OPTIONAL_INPUTS, ORIG_CTX_RETURN_TYPES, ORIG_CTX_RETURN_NAMES = _create_context_data(
_original_ctx_inputs_list)
def new_context(base_ctx, **kwargs):
"""Creates a new context from the provided data, with an optional base ctx to start."""
context = base_ctx if base_ctx is not None else None
new_ctx = {}
for key in _all_context_input_output_data:
if key == "base_ctx":
continue
v = kwargs[key] if key in kwargs else None
new_ctx[key] = v if v is not None else context[
key] if context is not None and key in context else None
return new_ctx
def merge_new_context(*args):
"""Creates a new context by merging provided contexts with the latter overriding same fields."""
new_ctx = {}
for key in _all_context_input_output_data:
if key == "base_ctx":
continue
v = None
# Move backwards through the passed contexts until we find a value and use it.
for ctx in reversed(args):
v = ctx[key] if not is_context_empty(ctx) and key in ctx else None
if v is not None:
break
new_ctx[key] = v
return new_ctx
def get_context_return_tuple(ctx, inputs_list=None):
"""Returns a tuple for returning in the order of the inputs list."""
if inputs_list is None:
inputs_list = _all_context_input_output_data.keys()
tup_list = [
ctx,
]
for key in inputs_list:
if key == "base_ctx":
continue
tup_list.append(ctx[key] if ctx is not None and key in ctx else None)
return tuple(tup_list)
def get_orig_context_return_tuple(ctx):
"""Returns a tuple for returning from a node with only the original context keys."""
return get_context_return_tuple(ctx, _original_ctx_inputs_list)
def is_context_empty(ctx):
"""Checks if the provided ctx is None or contains just None values."""
return not ctx or all(v is None for v in ctx.values())
+77
View File
@@ -0,0 +1,77 @@
import json
from .constants import get_category, get_name
from .utils import any_type, get_dict_value
class RgthreeDisplayAny:
"""Display any data node."""
NAME = get_name('Display Any')
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {
"source": (any_type, {}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ()
FUNCTION = "main"
OUTPUT_NODE = True
def main(self, source=None, unique_id=None, extra_pnginfo=None):
value = 'None'
if isinstance(source, str):
value = source
elif isinstance(source, (int, float, bool)):
value = str(source)
elif source is not None:
try:
value = json.dumps(source)
except Exception:
try:
value = str(source)
except Exception:
value = 'source exists, but could not be serialized.'
# Save the output to the pnginfo so it's pre-filled when loading the data.
if extra_pnginfo and unique_id:
for node in get_dict_value(extra_pnginfo, 'workflow.nodes', []):
if str(node['id']) == str(unique_id):
node['widgets_values'] = [value]
break
return {"ui": {"text": (value,)}}
class RgthreeDisplayInt:
"""Old DisplayInt node.
Can be ported over to DisplayAny if https://github.com/comfyanonymous/ComfyUI/issues/1527 fixed.
"""
NAME = get_name('Display Int')
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input": ("INT", {
"forceInput": True
}),
},
}
RETURN_TYPES = ()
FUNCTION = "main"
OUTPUT_NODE = True
def main(self, input=None):
return {"ui": {"text": (input,)}}
-23
View File
@@ -1,23 +0,0 @@
from .constants import get_category, get_name
class RgthreeDisplayInt:
NAME = get_name('Display Int')
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input": ("INT", {"forceInput": True}),
},
}
RETURN_TYPES = ()
FUNCTION = "main"
OUTPUT_NODE = True
def main(self, input=None):
return {"ui": {"text": (input,)}}
+56
View File
@@ -0,0 +1,56 @@
"""The Dynamic Context node."""
from mimetypes import add_type
from .constants import get_category, get_name
from .utils import ByPassTypeTuple, FlexibleOptionalInputType
class RgthreeDynamicContext:
"""The Dynamic Context node.
Similar to the static Context and Context Big nodes, this allows users to add any number and
variety of inputs to a Dynamic Context node, and return the outputs by key name.
"""
NAME = get_name("Dynamic Context")
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name,missing-function-docstring
return {
"required": {},
"optional": FlexibleOptionalInputType(add_type),
"hidden": {},
}
RETURN_TYPES = ByPassTypeTuple(("RGTHREE_DYNAMIC_CONTEXT",))
RETURN_NAMES = ByPassTypeTuple(("CONTEXT",))
FUNCTION = "main"
def main(self, **kwargs):
"""Creates a new context from the provided data, with an optional base ctx to start.
This node takes a list of named inputs that are the named keys (with an optional "+ " prefix)
which are to be stored within the ctx dict as well as a list of keys contained in `output_keys`
to determine the list of output data.
"""
base_ctx = kwargs.get('base_ctx', None)
output_keys = kwargs.get('output_keys', None)
new_ctx = base_ctx.copy() if base_ctx is not None else {}
for key_raw, value in kwargs.items():
if key_raw in ['base_ctx', 'output_keys']:
continue
key = key_raw.upper()
if key.startswith('+ '):
key = key[2:]
new_ctx[key] = value
print(new_ctx)
res = [new_ctx]
output_keys = output_keys.split(',') if output_keys is not None else []
for key in output_keys:
res.append(new_ctx[key] if key in new_ctx else None)
return tuple(res)
+39
View File
@@ -0,0 +1,39 @@
"""The original Context Switch."""
from .constants import get_category, get_name
from .context_utils import is_context_empty
from .utils import ByPassTypeTuple, FlexibleOptionalInputType
class RgthreeDynamicContextSwitch:
"""The initial Context Switch node."""
NAME = get_name("Dynamic Context Switch")
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": FlexibleOptionalInputType("RGTHREE_DYNAMIC_CONTEXT"),
}
RETURN_TYPES = ByPassTypeTuple(("RGTHREE_DYNAMIC_CONTEXT",))
RETURN_NAMES = ByPassTypeTuple(("CONTEXT",))
FUNCTION = "switch"
def switch(self, **kwargs):
"""Chooses the first non-empty Context to output."""
output_keys = kwargs.get('output_keys', None)
ctx = None
for key, value in kwargs.items():
if key.startswith('ctx_') and not is_context_empty(value):
ctx = value
break
res = [ctx]
output_keys = output_keys.split(',') if output_keys is not None else []
for key in output_keys:
res.append(ctx[key] if ctx is not None and key in ctx else None)
return tuple(res)
+45
View File
@@ -0,0 +1,45 @@
from nodes import PreviewImage
from .constants import get_category, get_name
class RgthreeImageComparer(PreviewImage):
"""A node that compares two images in the UI."""
NAME = get_name('Image Comparer')
CATEGORY = get_category()
FUNCTION = "compare_images"
DESCRIPTION = "Compares two images with a hover slider, or click from properties."
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": {
"image_a": ("IMAGE",),
"image_b": ("IMAGE",),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO"
},
}
def compare_images(self,
image_a=None,
image_b=None,
filename_prefix="rgthree.compare.",
prompt=None,
extra_pnginfo=None):
result = { "ui": { "a_images":[], "b_images": [] } }
if image_a is not None and len(image_a) > 0:
result['ui']['a_images'] = self.save_images(image_a, filename_prefix, prompt, extra_pnginfo)['ui']['images']
if image_b is not None and len(image_b) > 0:
result['ui']['b_images'] = self.save_images(image_b, filename_prefix, prompt, extra_pnginfo)['ui']['images']
# RETURN_TYPES is inherited from SaveImage, so the node advertises an IMAGE
# output. Without a 'result' key it stays empty and any link from it fails.
result['result'] = (image_a if image_a is not None else image_b,)
return result
+93
View File
@@ -0,0 +1,93 @@
"""Image Inset Crop, with percentages."""
from .log import log_node_info
from .constants import get_category, get_name
from nodes import MAX_RESOLUTION
def get_new_bounds(width, height, left, right, top, bottom):
"""Returns the new bounds for an image with inset crop data."""
left = 0 + left
right = width - right
top = 0 + top
bottom = height - bottom
return (left, right, top, bottom)
class RgthreeImageInsetCrop:
"""Image Inset Crop, with percentages."""
NAME = get_name('Image Inset Crop')
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {
"image": ("IMAGE",),
"measurement": (['Pixels', 'Percentage'],),
"left": ("INT", {
"default": 0,
"min": 0,
"max": MAX_RESOLUTION,
"step": 8
}),
"right": ("INT", {
"default": 0,
"min": 0,
"max": MAX_RESOLUTION,
"step": 8
}),
"top": ("INT", {
"default": 0,
"min": 0,
"max": MAX_RESOLUTION,
"step": 8
}),
"bottom": ("INT", {
"default": 0,
"min": 0,
"max": MAX_RESOLUTION,
"step": 8
}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "crop"
# pylint: disable = too-many-arguments
def crop(self, measurement, left, right, top, bottom, image=None):
"""Does the crop."""
_, height, width, _ = image.shape
if measurement == 'Percentage':
left = int(width - (width * (100 - left) / 100))
right = int(width - (width * (100 - right) / 100))
top = int(height - (height * (100 - top) / 100))
bottom = int(height - (height * (100 - bottom) / 100))
# Snap to 8 pixels
left = left // 8 * 8
right = right // 8 * 8
top = top // 8 * 8
bottom = bottom // 8 * 8
if left == 0 and right == 0 and bottom == 0 and top == 0:
return (image,)
inset_left, inset_right, inset_top, inset_bottom = get_new_bounds(width, height, left, right,
top, bottom)
if inset_top > inset_bottom:
raise ValueError(
f"Invalid cropping dimensions top ({inset_top}) exceeds bottom ({inset_bottom})")
if inset_left > inset_right:
raise ValueError(
f"Invalid cropping dimensions left ({inset_left}) exceeds right ({inset_right})")
log_node_info(
self.NAME, f'Cropping image {width}x{height} width inset by {inset_left},{inset_right}, ' +
f'and height inset by {inset_top}, {inset_bottom}')
image = image[:, inset_top:inset_bottom, inset_left:inset_right, :]
return (image,)
+31
View File
@@ -0,0 +1,31 @@
from .utils import FlexibleOptionalInputType, any_type
from .constants import get_category, get_name
class RgthreeImageOrLatentSize:
"""The ImageOrLatentSize Node."""
NAME = get_name('Image or Latent Size')
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ('WIDTH', 'HEIGHT')
FUNCTION = "main"
def main(self, **kwargs):
"""Does the node's work."""
image_or_latent_or_mask = kwargs.get('input', None)
if isinstance(image_or_latent_or_mask, dict) and 'samples' in image_or_latent_or_mask:
count, _, height, width = image_or_latent_or_mask['samples'].shape
return (width * 8, height * 8)
batch, height, width, channel = image_or_latent_or_mask.shape
return (width, height)
+117
View File
@@ -0,0 +1,117 @@
import torch
import comfy.utils
import nodes
from .constants import get_category, get_name
class RgthreeImageResize:
"""Image Resize."""
NAME = get_name("Image Resize")
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {
"image": ("IMAGE",),
"measurement": (["pixels", "percentage"],),
"width": (
"INT", {
"default": 0,
"min": 0,
"max": nodes.MAX_RESOLUTION,
"step": 1,
"tooltip": (
"The width of the desired resize. A pixel value if measurement is 'pixels' or a"
" 100% scale percentage value if measurement is 'percentage'. Passing '0' will"
" calculate the dimension based on the height."
),
},
),
"height": ("INT", {
"default": 0,
"min": 0,
"max": nodes.MAX_RESOLUTION,
"step": 1
}),
"fit": (["crop", "pad", "contain"], {
"tooltip": (
"'crop' resizes so the image covers the desired width and height, and center-crops the"
" excess, returning exactly the desired width and height."
"\n'pad' resizes so the image fits inside the desired width and height, and fills the"
" empty space returning exactly the desired width and height."
"\n'contain' resizes so the image fits inside the desired width and height, and"
" returns the image with it's new size, with one side liekly smaller than the desired."
"\n\nNote, if either width or height is '0', the effective fit is 'contain'."
)
},
),
"method": (nodes.ImageScale.upscale_methods,),
},
}
RETURN_TYPES = ("IMAGE", "INT", "INT",)
RETURN_NAMES = ("IMAGE", "WIDTH", "HEIGHT",)
FUNCTION = "main"
DESCRIPTION = """Resize the image."""
def main(self, image, measurement, width, height, method, fit):
"""Resizes the image."""
_, H, W, _ = image.shape
if measurement == "percentage":
width = round(width * W / 100)
height = round(height * H / 100)
if (width == 0 and height == 0) or (width == W and height == H):
return (image, W, H)
# If one dimension is 0, then calculate the desired value from the ratio of the set dimension.
# This also implies a 'contain' fit since the width and height will be scaled with a locked
# aspect ratio.
if width == 0 or height == 0:
width = round(height / H * W) if width == 0 else width
height = round(width / W * H) if height == 0 else height
fit = "contain"
# At this point, width and height are our output height, but our resize sizes will be different.
resized_width = width
resized_height = height
if fit == "crop":
# If we resize against the opposite ratio, then choose the ratio that has the overhang.
if (height / H * W) > width:
resized_width = round(height / H * W)
elif (width / W * H) > height:
resized_height = round(width / W * H)
elif fit == "contain" or fit == "pad":
# If we resize against the opposite ratio, then choose the ratio that has the overhang.
if (height / H * W) > width:
resized_height = round(width / W * H)
elif (width / W * H) > height:
resized_width = round(height / H * W)
out_image = comfy.utils.common_upscale(
image.clone().movedim(-1, 1), resized_width, resized_height, method, crop="disabled"
).movedim(1, -1)
OB, OH, OW, OC = out_image.shape
if fit != "contain":
# First, we crop, then we pad; no need to check fit (other than not 'contain') since the size
# should already be correct.
if OW > width:
out_image = out_image.narrow(-2, (OW - width) // 2, width)
if OH > height:
out_image = out_image.narrow(-3, (OH - height) // 2, height)
OB, OH, OW, OC = out_image.shape
if width != OW or height != OH:
padded_image = torch.zeros((OB, height, width, OC), dtype=image.dtype, device=image.device)
x = (width - OW) // 2
y = (height - OH) // 2
for b in range(OB):
padded_image[b, y:y + OH, x:x + OW, :] = out_image[b]
out_image = padded_image
return (out_image, out_image.shape[2], out_image.shape[1])
+56
View File
@@ -0,0 +1,56 @@
"""Some basic config stuff I use for SDXL."""
from .constants import get_category, get_name
from nodes import MAX_RESOLUTION
import comfy.samplers
class RgthreeKSamplerConfig:
"""Some basic config stuff I started using for SDXL, but useful in other spots too."""
NAME = get_name('KSampler Config')
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {
"steps_total": ("INT", {
"default": 30,
"min": 1,
"max": MAX_RESOLUTION,
"step": 1,
}),
"refiner_step": ("INT", {
"default": 24,
"min": 1,
"max": MAX_RESOLUTION,
"step": 1,
}),
"cfg": ("FLOAT", {
"default": 8.0,
"min": 0.0,
"max": 100.0,
"step": 0.5,
}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
#"refiner_ascore_pos": ("FLOAT", {"default": 6.0, "min": 0.0, "max": 1000.0, "step": 0.01}),
#"refiner_ascore_neg": ("FLOAT", {"default": 6.0, "min": 0.0, "max": 1000.0, "step": 0.01}),
},
}
RETURN_TYPES = ("INT", "INT", "FLOAT", comfy.samplers.KSampler.SAMPLERS,
comfy.samplers.KSampler.SCHEDULERS)
RETURN_NAMES = ("STEPS", "REFINER_STEP", "CFG", "SAMPLER", "SCHEDULER")
FUNCTION = "main"
def main(self, steps_total, refiner_step, cfg, sampler_name, scheduler):
"""main"""
return (
steps_total,
refiner_step,
cfg,
sampler_name,
scheduler,
)
+139 -61
View File
@@ -1,70 +1,148 @@
import datetime
import time
from .pyproject import NAME
# https://stackoverflow.com/questions/4842424/list-of-ansi-color-escape-sequences
# https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit
COLORS_FG = {
'BLACK': '\33[30m',
'RED': '\33[31m',
'GREEN': '\33[32m',
'YELLOW': '\33[33m',
'BLUE': '\33[34m',
'MAGENTA': '\33[35m',
'CYAN': '\33[36m',
'WHITE': '\33[37m',
'GREY': '\33[90m',
'BRIGHT_RED': '\33[91m',
'BRIGHT_GREEN': '\33[92m',
'BRIGHT_YELLOW': '\33[93m',
'BRIGHT_BLUE': '\33[94m',
'BRIGHT_MAGENTA': '\33[95m',
'BRIGHT_CYAN': '\33[96m',
'BRIGHT_WHITE': '\33[97m',
}
COLORS_STYLE = {
'RESET': '\33[0m',
'BOLD': '\33[1m',
'NORMAL': '\33[22m',
'ITALIC': '\33[3m',
'UNDERLINE': '\33[4m',
'BLINK': '\33[5m',
'BLINK2': '\33[6m',
'SELECTED': '\33[7m',
}
COLORS_BG = {
'BLACK': '\33[40m',
'RED': '\33[41m',
'GREEN': '\33[42m',
'YELLOW': '\33[43m',
'BLUE': '\33[44m',
'MAGENTA': '\33[45m',
'CYAN': '\33[46m',
'WHITE': '\33[47m',
'GREY': '\33[100m',
'BRIGHT_RED': '\33[101m',
'BRIGHT_GREEN': '\33[102m',
'BRIGHT_YELLOW': '\33[103m',
'BRIGHT_BLUE': '\33[104m',
'BRIGHT_MAGENTA': '\33[105m',
'BRIGHT_CYAN': '\33[106m',
'BRIGHT_WHITE': '\33[107m',
COLORS = {
'BLACK': '\33[30m',
'RED': '\33[31m',
'GREEN': '\33[32m',
'YELLOW': '\33[33m',
'BLUE': '\33[34m',
'MAGENTA': '\33[35m',
'CYAN': '\33[36m',
'WHITE': '\33[37m',
'GREY': '\33[90m',
'BRIGHT_RED': '\33[91m',
'BRIGHT_GREEN': '\33[92m',
'BRIGHT_YELLOW': '\33[93m',
'BRIGHT_BLUE': '\33[94m',
'BRIGHT_MAGENTA': '\33[95m',
'BRIGHT_CYAN': '\33[96m',
'BRIGHT_WHITE': '\33[97m',
# Styles.
'RESET': '\33[0m', # Note, Portainer doesn't like 00 here, so we'll use 0. Should be fine...
'BOLD': '\33[01m',
'NORMAL': '\33[22m',
'ITALIC': '\33[03m',
'UNDERLINE': '\33[04m',
'BLINK': '\33[05m',
'BLINK2': '\33[06m',
'SELECTED': '\33[07m',
# Backgrounds
'BG_BLACK': '\33[40m',
'BG_RED': '\33[41m',
'BG_GREEN': '\33[42m',
'BG_YELLOW': '\33[43m',
'BG_BLUE': '\33[44m',
'BG_MAGENTA': '\33[45m',
'BG_CYAN': '\33[46m',
'BG_WHITE': '\33[47m',
'BG_GREY': '\33[100m',
'BG_BRIGHT_RED': '\33[101m',
'BG_BRIGHT_GREEN': '\33[102m',
'BG_BRIGHT_YELLOW': '\33[103m',
'BG_BRIGHT_BLUE': '\33[104m',
'BG_BRIGHT_MAGENTA': '\33[105m',
'BG_BRIGHT_CYAN': '\33[106m',
'BG_BRIGHT_WHITE': '\33[107m',
}
def log_welcome(num_nodes=None):
msg='{}{}rgthree\'s comfy nodes:{}{} Loaded'.format(COLORS_FG['GREEN'], COLORS_STYLE['BOLD'], COLORS_STYLE['RESET'], COLORS_STYLE['BOLD'])
if num_nodes:
print('{} {} exciting nodes.{}'.format(msg, num_nodes, COLORS_STYLE['RESET']))
else:
print('{}.{}'.format(msg, COLORS_STYLE['RESET']))
def log_node_success(node_name, message):
_log_node(COLORS_FG["GREEN"], node_name, message, prefix='✓ ')
def log_node_success(node_name, message, msg_color='RESET'):
"""Logs a success message."""
_log_node("BRIGHT_GREEN", node_name, message, msg_color=msg_color)
def log_node_info(node_name, message):
_log_node(COLORS_FG["CYAN"], node_name, message, prefix='🛈 ')
def log_node(node_name, message):
_log_node(COLORS_FG["CYAN"], node_name, message, prefix=' ')
def log_node_info(node_name, message, msg_color='RESET'):
"""Logs an info message."""
_log_node("CYAN", node_name, message, msg_color=msg_color)
def log_node_warn(node_name, message):
_log_node(COLORS_FG["YELLOW"], node_name, message, prefix='⚠ ')
def _log_node(color, node_name, message, prefix=''):
print(f'{COLORS_STYLE["BOLD"]}{color}{prefix}rgthree {node_name.replace(" (rgthree)", "")}:{COLORS_STYLE["RESET"]} {message}')
def log_node_error(node_name, message, msg_color='RESET'):
"""Logs an info message."""
_log_node("RED", node_name, message, msg_color=msg_color)
def log_node_warn(node_name, message, msg_color='RESET'):
"""Logs an warn message."""
_log_node("YELLOW", node_name, message, msg_color=msg_color)
def log_node(node_name, message, msg_color='RESET'):
"""Logs a message."""
_log_node("CYAN", node_name, message, msg_color=msg_color)
def _log_node(color, node_name, message, msg_color='RESET'):
"""Logs for a node message."""
log(message, color=color, prefix=node_name.replace(" (rgthree)", ""), msg_color=msg_color)
LOGGED = {}
def log(message, color=None, msg_color=None, prefix=None, id=None, at_most_secs=None):
"""Basic logging."""
now = int(time.time())
if id:
if at_most_secs is None:
raise ValueError('at_most_secs should be set if an id is set.')
if id in LOGGED:
last_logged = LOGGED[id]
if now < last_logged + at_most_secs:
return
LOGGED[id] = now
color = COLORS[color] if color is not None and color in COLORS else COLORS["BRIGHT_GREEN"]
msg_color = COLORS[msg_color] if msg_color is not None and msg_color in COLORS else ''
prefix = f'[{prefix}]' if prefix is not None else ''
msg = f'{color}[{NAME}]{prefix}'
msg += f'{msg_color} {message}{COLORS["RESET"]}'
print(msg)
def log_known_message(message_type):
"""A central place to log known alerts or messages."""
if message_type == 'NODES2.0':
log(
"ComfyUI's new Node 2.0 rendering may be incompatible with some rgthree-comfy nodes "
"and features, breaking some rendering as well as losing the ability to "
"access a node's properties (a vital part of many nodes). It also appears to run MUCH more "
"slowly spiking CPU usage and causing jankiness and unresponsiveness, especially with large "
"workflows. Personally I am not planning to use the new Nodes 2.0 and, unfortunately, am not "
"able to invest the time to investigate and overhaul rgthree-comfy where needed. "
"If you have issues when Nodes 2.0 is enabled, I'd urge you to switch it off as well and "
"join me in hoping ComfyUI is not planning to deprecate the existing, stable canvas "
"rendering all together.\n",
color="YELLOW",
id="announcements.comfy-nodes-20.incompatible",
at_most_secs=60
)
elif message_type == 'PRIMITIVE_REROUTE':
log(
"You are using rgthree-comfy reroutes with a ComfyUI Primitive node. Unfortunately, ComfyUI "
"has removed support for this. While rgthree-comfy has a best-effort support fallback for "
"now, it may no longer work as expected and is strongly recommended you either replace the "
"Reroute node using ComfyUI's reroute node, or refrain from using the Primitive node "
"(you can always use the rgthree-comfy \"Power Primitive\" for non-combo primitives).",
prefix="Reroute",
color="YELLOW",
id=message_type,
at_most_secs=20
)
elif message_type == 'COMFYUI_SWWAN':
log(
"The installed custom node \"ComfyUI_Swwan\" conflicts with \"rgthree-comfy\" by crudely "
"copying its code causing it to no longer function correctly when both are installed. To "
"restore \"rgthree-comfy\" you may need to remove \"ComfyUI_Swwan,\" restart your server and "
"refresh the browser.\n",
prefix="Conflict",
color="YELLOW",
msg_color="RESET",
id=message_type,
at_most_secs=60
)
else:
return False
return True
+1 -1
View File
@@ -9,7 +9,7 @@ class RgthreeLoraLoaderStack:
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(s):
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {
"model": ("MODEL",),
+101
View File
@@ -0,0 +1,101 @@
import folder_paths
from typing import Union
from nodes import LoraLoader
from .constants import get_category, get_name
from .power_prompt_utils import get_lora_by_filename
from .utils import FlexibleOptionalInputType, any_type
from .server.utils_info import get_model_info_file_data
from .log import log_node_warn
NODE_NAME = get_name('Power Lora Loader')
class RgthreePowerLoraLoader:
""" The Power Lora Loader is a powerful, flexible node to add multiple loras to a model/clip."""
NAME = NODE_NAME
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {
},
# Since we will pass any number of loras in from the UI, this needs to always allow an
"optional": FlexibleOptionalInputType(type=any_type, data={
"model": ("MODEL",),
"clip": ("CLIP",),
}),
"hidden": {},
}
RETURN_TYPES = ("MODEL", "CLIP")
RETURN_NAMES = ("MODEL", "CLIP")
FUNCTION = "load_loras"
def load_loras(self, model=None, clip=None, **kwargs):
"""Loops over the provided loras in kwargs and applies valid ones."""
for key, value in kwargs.items():
key = key.upper()
if key.startswith('LORA_') and 'on' in value and 'lora' in value and 'strength' in value:
strength_model = value['strength']
# If we just passed one strength value, then use it for both, if we passed a strengthTwo
# as well, then our `strength` will be for the model, and `strengthTwo` for clip.
strength_clip = value['strengthTwo'] if 'strengthTwo' in value else None
if clip is None:
if strength_clip is not None and strength_clip != 0:
log_node_warn(NODE_NAME, 'Recieved clip strength eventhough no clip supplied!')
strength_clip = 0
else:
strength_clip = strength_clip if strength_clip is not None else strength_model
if value['on'] and (strength_model != 0 or strength_clip != 0):
lora = get_lora_by_filename(value['lora'], log_node=self.NAME)
if model is not None and lora is not None:
model, clip = LoraLoader().load_lora(model, clip, lora, strength_model, strength_clip)
return (model, clip)
@classmethod
def get_enabled_loras_from_prompt_node(cls,
prompt_node: dict) -> list[dict[str, Union[str, float]]]:
"""Gets enabled loras of a node within a server prompt."""
result = []
for name, lora in prompt_node['inputs'].items():
if name.startswith('lora_') and lora['on']:
lora_file = get_lora_by_filename(lora['lora'], log_node=cls.NAME)
if lora_file is not None: # Add the same safety check
lora_dict = {
'name': lora['lora'],
'strength': lora['strength'],
'path': folder_paths.get_full_path("loras", lora_file)
}
if 'strengthTwo' in lora:
lora_dict['strength_clip'] = lora['strengthTwo']
result.append(lora_dict)
return result
@classmethod
def get_enabled_triggers_from_prompt_node(cls, prompt_node: dict, max_each: int = 1):
"""Gets trigger words up to the max for enabled loras of a node within a server prompt."""
loras = [l['name'] for l in cls.get_enabled_loras_from_prompt_node(prompt_node)]
trained_words = []
for lora in loras:
info = get_model_info_file_data(lora, 'loras', default={})
if not info or not info.keys():
log_node_warn(
NODE_NAME,
f'No info found for lora {lora} when grabbing triggers. Have you generated an info file'
' from the Power Lora Loader "Show Info" dialog?'
)
continue
if 'trainedWords' not in info or not info['trainedWords']:
log_node_warn(
NODE_NAME,
f'No trained words for lora {lora} when grabbing triggers. Have you fetched data from'
'civitai or manually added words?'
)
continue
trained_words += [w for wi in info['trainedWords'][:max_each] if (wi and (w := wi['word']))]
return trained_words
+83
View File
@@ -0,0 +1,83 @@
import re
from .utils import FlexibleOptionalInputType, any_type
from .constants import get_category, get_name
def cast_to_str(x):
"""Handles our cast to a string."""
if x is None:
return ''
try:
return str(x)
except (ValueError, TypeError):
return ''
def cast_to_float(x):
"""Handles our cast to a float."""
try:
return float(x)
except (ValueError, TypeError):
return 0.0
def cast_to_bool(x):
"""Handles our cast to a bool."""
try:
return bool(float(x))
except (ValueError, TypeError):
return str(x).lower() not in ['0', 'false', 'null', 'none', '']
output_to_type = {
'STRING': {
'cast': cast_to_str,
'null': '',
},
'FLOAT': {
'cast': cast_to_float,
'null': 0.0,
},
'INT': {
'cast': lambda x: int(cast_to_float(x)),
'null': 0,
},
'BOOLEAN': {
'cast': cast_to_bool,
'null': False,
},
# This can be removed soon, there was a bug where this should have been BOOLEAN
'BOOL': {
'cast': cast_to_bool,
'null': False,
},
}
class RgthreePowerPrimitive:
"""The Power Primitive Node."""
NAME = get_name('Power Primitive')
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = (any_type,)
RETURN_NAMES = ('*',)
FUNCTION = "main"
def main(self, **kwargs):
"""Outputs the expected type."""
output = kwargs.get('value', None)
output_type = re.sub(r'\s*\([^\)]*\)\s*$', '', kwargs.get('type', ''))
output_type = output_to_type[output_type]
cast = output_type['cast']
output = cast(output)
return (output,)
+78 -87
View File
@@ -1,104 +1,95 @@
import os
import re
from .log import log_node_warn, log_node_info, log_node_success
from .constants import get_category, get_name
from .power_prompt_utils import get_and_strip_loras
from nodes import LoraLoader, CLIPTextEncode
import folder_paths
NODE_NAME=get_name('Power Prompt')
def get_and_strip_loras(prompt, silent=False):
pattern='<lora:([^:>]*?)(?::(-?\d*(?:\.\d*)?))?>'
lora_paths=folder_paths.get_filename_list('loras')
lora_filenames_no_ext=[os.path.splitext(os.path.basename(x))[0] for x in lora_paths]
matches = re.findall(pattern, prompt)
loras=[]
for match in matches:
tag_filename=match[0]
strength=float(match[1] if len(match) > 1 and len(match[1]) else 1.0)
if strength == 0 and not silent:
log_node_info(NODE_NAME, f'Skipping "{tag_filename}" with strength of zero')
# Let's be flexible. If the lora filename in the tag doesn't have the extension or
# path prefix, let's still find and load it.
if tag_filename not in lora_paths:
found_tag_filename=None
for index, value in enumerate(lora_filenames_no_ext):
if value in tag_filename:
found_tag_filename=lora_paths[index]
break
if found_tag_filename:
# if not silent:
# log_node_info(NODE_NAME, f'Found "{found_tag_filename}" for "{tag_filename}" in prompt')
tag_filename=found_tag_filename
else:
if not silent:
log_node_warn(NODE_NAME, f'Lora "{tag_filename}" not found, skipping.')
continue
loras.append({'lora':tag_filename, 'strength':strength})
return (re.sub(pattern, '', prompt), loras)
NODE_NAME = get_name('Power Prompt')
class RgthreePowerPrompt:
NAME = NODE_NAME
CATEGORY = get_category()
NAME = NODE_NAME
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(s):
SAVED_PROMPTS_FILES=folder_paths.get_filename_list('saved_prompts')
SAVED_PROMPTS_CONTENT=[]
for filename in SAVED_PROMPTS_FILES:
with open(folder_paths.get_full_path('saved_prompts', filename), 'r') as f:
SAVED_PROMPTS_CONTENT.append(f.read())
return {
'required': {
'prompt': ('STRING', {'multiline': True}),
},
'optional': {
"opt_model": ("MODEL",),
"opt_clip": ("CLIP", ),
'insert_lora': (['CHOOSE', 'DISABLE LORAS'] + [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('loras')],),
'insert_embedding': (['CHOOSE',] + [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('embeddings')],),
'insert_saved': (['CHOOSE',] + SAVED_PROMPTS_FILES,),
},
'hidden': {
'values_insert_saved': (['CHOOSE'] + SAVED_PROMPTS_CONTENT,),
}
}
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
# Removed Saved Prompts feature; No sure it worked any longer. UI should fail gracefully,
# TODO: Rip out saved prompt input data
SAVED_PROMPTS_FILES=[]
SAVED_PROMPTS_CONTENT=[]
return {
'required': {
'prompt': ('STRING', {
'multiline': True,
'dynamicPrompts': True
}),
},
'optional': {
"opt_model": ("MODEL",),
"opt_clip": ("CLIP",),
'insert_lora': (['CHOOSE', 'DISABLE LORAS'] +
[os.path.splitext(x)[0] for x in folder_paths.get_filename_list('loras')],),
'insert_embedding': ([
'CHOOSE',
] + [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('embeddings')],),
'insert_saved': ([
'CHOOSE',
] + SAVED_PROMPTS_FILES,),
},
'hidden': {
'values_insert_saved': (['CHOOSE'] + SAVED_PROMPTS_CONTENT,),
}
}
RETURN_TYPES = ('CONDITIONING', 'MODEL', 'CLIP', 'STRING',)
RETURN_NAMES = ('CONDITIONING', 'MODEL', 'CLIP', 'TEXT',)
FUNCTION = 'main'
RETURN_TYPES = (
'CONDITIONING',
'MODEL',
'CLIP',
'STRING',
)
RETURN_NAMES = (
'CONDITIONING',
'MODEL',
'CLIP',
'TEXT',
)
FUNCTION = 'main'
def main(self, prompt, opt_model=None, opt_clip=None, insert_lora=None, insert_embedding=None, insert_saved=None, values_insert_saved=None):
if insert_lora == 'DISABLE LORAS':
prompt, loras = get_and_strip_loras(prompt, True)
log_node_info(NODE_NAME, f'Disabling all found loras ({len(loras)}) and stripping lora tags for TEXT output.')
elif opt_model != None and opt_clip != None:
prompt, loras = get_and_strip_loras(prompt)
if len(loras):
for lora in loras:
opt_model, opt_clip = LoraLoader().load_lora(opt_model, opt_clip, lora['lora'], lora['strength'], lora['strength'])
log_node_success(NODE_NAME, f'Loaded "{lora["lora"]}" from prompt')
log_node_info(NODE_NAME, f'{len(loras)} Loras processed; stripping tags for TEXT output.')
elif '<lora:' in prompt:
_prompt_stripped, loras = get_and_strip_loras(prompt, True)
if len(loras):
log_node_warn(NODE_NAME, f'Found {len(loras)} lora tags in prompt but model & clip were not supplied!')
log_node_info(NODE_NAME, f'Loras not processed, keeping for TEXT output.')
def main(self,
prompt,
opt_model=None,
opt_clip=None,
insert_lora=None,
insert_embedding=None,
insert_saved=None,
values_insert_saved=None):
if insert_lora == 'DISABLE LORAS':
prompt, loras, skipped, unfound = get_and_strip_loras(prompt, log_node=NODE_NAME, silent=True)
log_node_info(
NODE_NAME,
f'Disabling all found loras ({len(loras)}) and stripping lora tags for TEXT output.')
elif opt_model is not None and opt_clip is not None:
prompt, loras, skipped, unfound = get_and_strip_loras(prompt, log_node=NODE_NAME)
if len(loras) > 0:
for lora in loras:
opt_model, opt_clip = LoraLoader().load_lora(opt_model, opt_clip, lora['lora'],
lora['strength'], lora['strength'])
log_node_success(NODE_NAME, f'Loaded "{lora["lora"]}" from prompt')
log_node_info(NODE_NAME, f'{len(loras)} Loras processed; stripping tags for TEXT output.')
elif '<lora:' in prompt:
prompt, loras, skipped, unfound = get_and_strip_loras(prompt, log_node=NODE_NAME, silent=True)
total_loras = len(loras) + len(skipped) + len(unfound)
if total_loras:
log_node_warn(
NODE_NAME, f'Found {len(loras)} lora tags in prompt but model & clip were not supplied!')
log_node_info(NODE_NAME, 'Loras not processed, keeping for TEXT output.')
conditioning=None
if opt_clip != None:
conditioning = CLIPTextEncode().encode(opt_clip, prompt)[0]
return (conditioning, opt_model, opt_clip, prompt)
conditioning = None
if opt_clip is not None:
conditioning = CLIPTextEncode().encode(opt_clip, prompt)[0]
return (conditioning, opt_model, opt_clip, prompt)
+5 -6
View File
@@ -10,15 +10,14 @@ class RgthreePowerPromptSimple(RgthreePowerPrompt):
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(s):
SAVED_PROMPTS_FILES=folder_paths.get_filename_list('saved_prompts')
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
# Removed Saved Prompts feature; No sure it worked any longer. UI should fail gracefully,
# TODO: Rip out saved prompt input data
SAVED_PROMPTS_FILES=[]
SAVED_PROMPTS_CONTENT=[]
for filename in SAVED_PROMPTS_FILES:
with open(folder_paths.get_full_path('saved_prompts', filename), 'r') as f:
SAVED_PROMPTS_CONTENT.append(f.read())
return {
'required': {
'prompt': ('STRING', {'multiline': True}),
'prompt': ('STRING', {'multiline': True, 'dynamicPrompts': True}),
},
'optional': {
"opt_clip": ("CLIP", ),
+104
View File
@@ -0,0 +1,104 @@
"""Utilities for Power Prompt nodes."""
import re
import os
import folder_paths
from .log import log_node_warn, log_node_info
def get_and_strip_loras(prompt, silent=False, log_node="Power Prompt"):
"""Collects and strips lora tags from a prompt."""
pattern = r'<lora:([^:>]*?)(?::(-?\d*(?:\.\d*)?))?>'
lora_paths = folder_paths.get_filename_list('loras')
matches = re.findall(pattern, prompt)
loras = []
unfound_loras = []
skipped_loras = []
for match in matches:
tag_path = match[0]
strength = float(match[1] if len(match) > 1 and len(match[1]) else 1.0)
if strength == 0:
if not silent:
log_node_info(log_node, f'Skipping "{tag_path}" with strength of zero')
skipped_loras.append({'lora': tag_path, 'strength': strength})
continue
lora_path = get_lora_by_filename(tag_path, lora_paths, log_node=None if silent else log_node)
if lora_path is None:
unfound_loras.append({'lora': tag_path, 'strength': strength})
continue
loras.append({'lora': lora_path, 'strength': strength})
return (re.sub(pattern, '', prompt), loras, skipped_loras, unfound_loras)
# pylint: disable = too-many-return-statements, too-many-branches
def get_lora_by_filename(file_path, lora_paths=None, log_node=None):
"""Returns a lora by filename, looking for exactl paths and then fuzzier matching."""
lora_paths = lora_paths if lora_paths is not None else folder_paths.get_filename_list('loras')
if file_path in lora_paths:
return file_path
lora_paths_no_ext = [os.path.splitext(x)[0] for x in lora_paths]
# See if we've entered the exact path, but without the extension
if file_path in lora_paths_no_ext:
found = lora_paths[lora_paths_no_ext.index(file_path)]
return found
# Same check, but ensure file_path is without extension.
file_path_force_no_ext = os.path.splitext(file_path)[0]
if file_path_force_no_ext in lora_paths_no_ext:
found = lora_paths[lora_paths_no_ext.index(file_path_force_no_ext)]
return found
# See if we passed just the name, without paths.
lora_filenames_only = [os.path.basename(x) for x in lora_paths]
if file_path in lora_filenames_only:
found = lora_paths[lora_filenames_only.index(file_path)]
if log_node is not None:
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
return found
# Same, but force the input to be without paths
file_path_force_filename = os.path.basename(file_path)
lora_filenames_only = [os.path.basename(x) for x in lora_paths]
if file_path_force_filename in lora_filenames_only:
found = lora_paths[lora_filenames_only.index(file_path_force_filename)]
if log_node is not None:
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
return found
# Check the filenames and without extension.
lora_filenames_and_no_ext = [os.path.splitext(os.path.basename(x))[0] for x in lora_paths]
if file_path in lora_filenames_and_no_ext:
found = lora_paths[lora_filenames_and_no_ext.index(file_path)]
if log_node is not None:
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
return found
# And, one last forcing the input to be the same
file_path_force_filename_and_no_ext = os.path.splitext(os.path.basename(file_path))[0]
if file_path_force_filename_and_no_ext in lora_filenames_and_no_ext:
found = lora_paths[lora_filenames_and_no_ext.index(file_path_force_filename_and_no_ext)]
if log_node is not None:
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
return found
# Finally, super fuzzy, we'll just check if the input exists in the path at all.
for index, lora_path in enumerate(lora_paths):
if file_path in lora_path:
found = lora_paths[index]
if log_node is not None:
log_node_warn(log_node, f'Fuzzy-matched Lora input "{file_path}" to "{found}".')
return found
if log_node is not None:
log_node_warn(log_node, f'Lora "{file_path}" not found, skipping.')
return None
+855
View File
@@ -0,0 +1,855 @@
"""The Power Puter is a powerful node that can compute and evaluate Python-like code safely allowing
for complex operations for primitives and workflow items for output. From string concatenation, to
math operations, list comprehension, and node value output.
Originally based off https://github.com/pythongosssss/ComfyUI-Custom-Scripts/blob/aac13aa7ce35b07d43633c3bbe654a38c00d74f5/py/math_expression.py
under an MIT License https://github.com/pythongosssss/ComfyUI-Custom-Scripts/blob/aac13aa7ce35b07d43633c3bbe654a38c00d74f5/LICENSE
"""
import math
import ast
import json
import random
import dataclasses
import re
import time
import operator as op
import datetime
import numpy as np
import hashlib
from typing import Any, Callable, Iterable, Optional, Union
from types import MappingProxyType
from .constants import get_category, get_name
from .utils import ByPassTypeTuple, FlexibleOptionalInputType, any_type, get_dict_value
from .log import log_node_error, log_node_warn, log_node_info
from .power_lora_loader import RgthreePowerLoraLoader
from nodes import ImageBatch
from comfy_extras.nodes_latent import LatentBatch
AstNumConstantClasses = (ast.Num, ast.Constant) if hasattr(ast, "Num") else (ast.Constant)
class LoopBreak(Exception):
"""A special error type that is caught in a loop for correct breaking behavior."""
def __init__(self):
super().__init__('Cannot use "break" outside of a loop.')
class LoopContinue(Exception):
"""A special error type that is caught in a loop for correct continue behavior."""
def __init__(self):
super().__init__('Cannot use "continue" outside of a loop.')
@dataclasses.dataclass(frozen=True) # Note, kw_only=True is only python 3.10+
class Function():
"""Function data.
Attributes:
name: The name of the function as called from the node.
call: The callable (reference, lambda, etc), or a string if on _Puter instance.
args: A tuple that represents the minimum and maximum number of args (or arg for no limit).
"""
name: str
call: Union[Callable, str]
args: tuple[int, Optional[int]]
def purge_vram(purge_models=True):
"""Purges vram and, optionally, unloads models."""
import gc
import torch
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
if purge_models:
import comfy
comfy.model_management.unload_all_models()
comfy.model_management.soft_empty_cache()
def batch(*args):
"""Batches multiple image or latents together."""
def check_is_latent(item) -> bool:
return isinstance(item, dict) and 'samples' in item
args = list(args)
result = args.pop(0)
is_latent = check_is_latent(result)
node = LatentBatch() if is_latent else ImageBatch()
for arg in args:
if is_latent != check_is_latent(arg):
raise ValueError(
f'batch() error: Expecting "{"LATENT" if is_latent else "IMAGE"}"'
f' but got "{"IMAGE" if is_latent else "LATENT"}".'
)
result = node.batch(result, arg)[0]
return result
_BUILTIN_FN_PREFIX = '__rgthreefn.'
def _get_built_in_fn_key(fn: Function) -> str:
"""Returns a key for a built-in function."""
return f'{_BUILTIN_FN_PREFIX}{hash(fn.name)}'
def _get_built_in_fn_by_key(fn_key: str):
"""Returns the `Function` for the provided key (purposefully, not name)."""
if not fn_key.startswith(_BUILTIN_FN_PREFIX) or fn_key not in _BUILT_INS_BY_NAME_AND_KEY:
raise ValueError('No built in function found.')
return _BUILT_INS_BY_NAME_AND_KEY[fn_key]
def sha264(message: str):
if isinstance(message, str):
return hashlib.sha256(message.encode()).hexdigest()
return None
_BUILT_IN_FNS_LIST = [
Function(name="round", call=round, args=(1, 2)),
Function(name="ceil", call=math.ceil, args=(1, 1)),
Function(name="floor", call=math.floor, args=(1, 1)),
Function(name="sqrt", call=math.sqrt, args=(1, 1)),
Function(name="min", call=min, args=(2, None)),
Function(name="max", call=max, args=(2, None)),
Function(name=".random_int", call=random.randint, args=(2, 2)),
Function(name=".random_choice", call=random.choice, args=(1, 1)),
Function(name=".random_seed", call=random.seed, args=(1, 1)),
Function(name="re", call=re.compile, args=(1, 1)),
Function(name="len", call=len, args=(1, 1)),
Function(name="enumerate", call=enumerate, args=(1, 1)),
Function(name="range", call=range, args=(1, 3)),
# Dates / Time
Function(name="now", call=datetime.datetime.now, args=(0, 1)),
Function(name="strftime", call=lambda f: datetime.datetime.now().strftime(f), args=(0, 1)),
# Casts
Function(name="int", call=int, args=(1, 1)),
Function(name="float", call=float, args=(1, 1)),
Function(name="str", call=str, args=(1, 1)),
Function(name="bool", call=bool, args=(1, 1)),
Function(name="list", call=list, args=(1, 1)),
Function(name="tuple", call=tuple, args=(1, 1)),
# Special
Function(name="dir", call=dir, args=(1, 1)),
Function(name="type", call=type, args=(1, 1)),
Function(name="print", call=print, args=(0, None)),
Function(name="sha264", call=sha264, args=(1, 1)),
# Comfy Specials
Function(name="node", call='_get_node', args=(0, 1)),
Function(name="nodes", call='_get_nodes', args=(0, 1)),
Function(name="input_node", call='_get_input_node', args=(0, 1)),
Function(name="purge_vram", call=purge_vram, args=(0, 1)),
Function(name="batch", call=batch, args=(2, None)),
]
_BUILT_INS_BY_NAME_AND_KEY = {
fn.name: fn for fn in _BUILT_IN_FNS_LIST
} | {
key: fn for fn in _BUILT_IN_FNS_LIST if (key := _get_built_in_fn_key(fn))
}
_BUILT_INS = MappingProxyType(
{fn.name: key for fn in _BUILT_IN_FNS_LIST if (key := _get_built_in_fn_key(fn))} | {
'random':
MappingProxyType({
'int': _get_built_in_fn_key(_BUILT_INS_BY_NAME_AND_KEY['.random_int']),
'choice': _get_built_in_fn_key(_BUILT_INS_BY_NAME_AND_KEY['.random_choice']),
'seed': _get_built_in_fn_key(_BUILT_INS_BY_NAME_AND_KEY['.random_seed']),
}),
# Access all of datetime import.
'datetime': datetime,
}
)
# A dict of types to blocked attributes/methods. Used to disallow file system access or other
# invocations we may want to block. Necessary for any instance type that is possible to create from
# the code or standard ComfyUI inputs.
#
# For instance, a user does not have access to the numpy module directly, so they cannot invoke
# `numpy.save`. However, a user can access a numpy.ndarray instance from a tensor and, from there,
# an attempt to call `tofile` or `dump` etc. would need to be blocked.
_BLOCKED_METHODS_OR_ATTRS = MappingProxyType({np.ndarray: ['tofile', 'dump']})
# Special functions by class type (called from the Attrs.)
_SPECIAL_FUNCTIONS = {
RgthreePowerLoraLoader.NAME: {
# Get a list of the enabled loras from a power lora loader.
"loras": RgthreePowerLoraLoader.get_enabled_loras_from_prompt_node,
"triggers": RgthreePowerLoraLoader.get_enabled_triggers_from_prompt_node,
}
}
# Series of regex checks for usage of a non-deterministic function. Using these is fine, but means
# the output can't be cached because it's either random, or is associated with another node that is
# not connected to ours (like looking up a node in the prompt). Using these means downstream nodes
# would always be run; that is fine for something like a final JSON output, but less so for a prompt
# text.
_NON_DETERMINISTIC_FUNCTION_CHECKS = [r'(?<!input_)(nodes?)\(',]
_OPERATORS = {
# operator
ast.Add: op.add,
ast.Sub: op.sub,
ast.Mult: op.mul,
ast.MatMult: op.matmul,
ast.Div: op.truediv,
ast.Mod: op.mod,
ast.Pow: op.pow,
ast.RShift: op.rshift,
ast.LShift: op.lshift,
ast.BitOr: op.or_,
ast.BitXor: op.xor,
ast.BitAnd: op.and_,
ast.FloorDiv: op.floordiv,
# boolop
ast.And: lambda a, b: a and b,
ast.Or: lambda a, b: a or b,
# unaryop
ast.Invert: op.invert,
ast.Not: lambda a: 0 if a else 1,
ast.USub: op.neg,
# cmpop
ast.Eq: op.eq,
ast.NotEq: op.ne,
ast.Lt: op.lt,
ast.LtE: op.le,
ast.Gt: op.gt,
ast.GtE: op.ge,
ast.Is: op.is_,
ast.IsNot: op.is_not,
ast.In: lambda a, b: a in b,
ast.NotIn: lambda a, b: a not in b,
}
_NODE_NAME = get_name("Power Puter")
def _update_code(code: str, unique_id: str, log=False):
"""Updates the code to either newer syntax or general cleaning."""
# Change usage of `input_node` so the passed variable is a string, if it isn't. So, instead of
# `input_node(a)` it needs to be `input_node('a')`
code = re.sub(r'input_node\(([^\'"].*?)\)', r'input_node("\1")', code)
# Update use of `random_int` to `random.int`
srch = re.compile(r'random_int\(')
if re.search(srch, code):
if log:
log_node_warn(
_NODE_NAME, f"Power Puter node #{unique_id} should update to use the `random.int`"
" built-in instead of `random_int`."
)
code = re.sub(srch, 'random.int(', code)
# Update use of `random_choice` to `random.choice`
srch = re.compile(r'random_choice\(')
if re.search(srch, code):
if log:
log_node_warn(
_NODE_NAME, f"Power Puter node #{unique_id} should update to use the `random.choice`"
" built-in instead of `random_choice`."
)
code = re.sub(srch, 'random.choice(', code)
return code
class RgthreePowerPuter:
"""A powerful node that can compute and evaluate expressions and output as various types."""
NAME = _NODE_NAME
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {},
"optional": FlexibleOptionalInputType(any_type),
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
"prompt": "PROMPT",
"dynprompt": "DYNPROMPT"
},
}
RETURN_TYPES = ByPassTypeTuple((any_type,))
RETURN_NAMES = ByPassTypeTuple(("*",))
FUNCTION = "main"
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Forces a changed state if we could be unaware of data changes (like using `node()`)."""
code = _update_code(kwargs['code'], unique_id=kwargs['unique_id'])
# Strip string literals and comments.
code = re.sub(r"'[^']+?'", "''", code)
code = re.sub(r'"[^"]+?"', '""', code)
code = re.sub(r'#.*\n', '\n', code)
# If we have a non-deterministic function, then we'll always consider ourself changed since we
# cannot be sure that the data would be the same (random, another unconnected node, etc).
for check in _NON_DETERMINISTIC_FUNCTION_CHECKS:
matches = re.search(check, code)
if matches:
log_node_warn(
_NODE_NAME,
f"Note, Power Puter (node #{kwargs['unique_id']}) cannot be cached b/c it's using a"
f" non-deterministic function call. Matches function call for '{matches.group(1)}'."
)
return time.time()
# Advanced checks.
has_rand_seed = re.search(r'random\.seed\(', code)
has_rand_int_or_choice = re.search(r'(?<!\.)(random\.(int|choice))\(', code)
if has_rand_int_or_choice:
if not has_rand_seed or has_rand_seed.span()[0] > has_rand_int_or_choice.span()[0]:
log_node_warn(
_NODE_NAME,
f"Note, Power Puter (node #{kwargs['unique_id']}) cannot be cached b/c it's using a"
" non-deterministic function call. Matches function call for"
f" `{has_rand_int_or_choice.group(1)}`."
)
return time.time()
if has_rand_seed:
log_node_info(
_NODE_NAME,
f"Power Puter node #{kwargs['unique_id']} WILL be cached eventhough it's using"
f" a non-deterministic random call `{has_rand_int_or_choice.group(1)}` because it also"
f" calls `random.seed` first. NOTE: Please ensure that the seed value is deterministic."
)
return 42
def main(self, **kwargs):
"""Does the nodes' work."""
code = kwargs['code']
unique_id = kwargs['unique_id']
pnginfo = kwargs['extra_pnginfo']
workflow = pnginfo["workflow"] if "workflow" in pnginfo else {"nodes": []}
prompt = kwargs['prompt']
dynprompt = kwargs['dynprompt']
outputs = get_dict_value(kwargs, 'outputs.outputs', None)
if not outputs:
output = kwargs.get('output', None)
if not output:
output = 'STRING'
outputs = [output]
ctx = {}
# Set variable names, defaulting to None instead of KeyErrors
for c in list('abcdefghijklmnopqrstuvwxyz'):
ctx[c] = kwargs[c] if c in kwargs else None
code = _update_code(kwargs['code'], unique_id=kwargs['unique_id'], log=True)
eva = _Puter(
code=code,
ctx=ctx,
workflow=workflow,
prompt=prompt,
dynprompt=dynprompt,
unique_id=unique_id
)
values = eva.execute()
# Check if we have multiple outputs that the returned value is a tuple and raise if not.
if len(outputs) > 1 and not isinstance(values, tuple):
t = re.sub(r'^<[a-z]*\s(.*?)>$', r'\1', str(type(values)))
msg = (
f"When using multiple node outputs, the value from the code should be a 'tuple' with the"
f" number of items equal to the number of outputs. But value from code was of type {t}."
)
log_node_error(_NODE_NAME, f'{msg}\n')
raise ValueError(msg)
if len(outputs) == 1:
values = (values,)
if len(values) > len(outputs):
log_node_warn(
_NODE_NAME,
f"Expected value from code to be tuple with {len(outputs)} items, but value from code had"
f" {len(values)} items. Extra values will be dropped."
)
elif len(values) < len(outputs):
log_node_warn(
_NODE_NAME,
f"Expected value from code to be tuple with {len(outputs)} items, but value from code had"
f" {len(values)} items. Extra outputs will be null."
)
# Now, we'll go over out return tuple, and cast as the output types.
response = []
for i, output in enumerate(outputs):
value = values[i] if len(values) > i else None
if value is not None:
if output == 'INT':
value = int(value)
elif output == 'FLOAT':
value = float(value)
# Accidentally defined "BOOL" when should have been "BOOLEAN."
# TODO: Can prob get rid of BOOl after a bit when UIs would be updated from sending
# BOOL incorrectly.
elif output in ('BOOL', 'BOOLEAN'):
value = bool(value)
elif output == 'STRING':
if isinstance(value, (dict, list)):
value = json.dumps(value, indent=2)
else:
value = str(value)
elif output == '*':
# Do nothing, the output will be passed as-is. This could be anything and it's up to the
# user to control the intended output, like passing through an input value, etc.
pass
response.append(value)
return tuple(response)
class _Puter:
"""The main computation evaluator, using ast.parse the code.
See https://www.basicexamples.com/example/python/ast for examples.
"""
def __init__(self, *, code: str, ctx: dict[str, Any], workflow, prompt, dynprompt, unique_id):
ctx = ctx or {}
self._ctx = {**ctx}
self._code = code
self._workflow = workflow
self._prompt = prompt
self._unique_id = unique_id
self._dynprompt = dynprompt
# These are now expanded lazily when needed.
self._prompt_nodes = None
self._prompt_node = None
def execute(self, code=Optional[str]) -> Any:
"""Evaluates a the code block."""
# Always store random state and initialize a new seed. We'll restore the state later.
initial_random_state = random.getstate()
random.seed(datetime.datetime.now().timestamp())
last_value = None
try:
code = code or self._code
node = ast.parse(self._code)
ctx = {**self._ctx}
for body in node.body:
last_value = self._eval_statement(body, ctx)
# If we got a return, then that's it folks.
if isinstance(body, ast.Return):
break
except:
random.setstate(initial_random_state)
raise
random.setstate(initial_random_state)
return last_value
def _get_prompt_nodes(self):
"""Expands the prompt nodes lazily from the dynamic prompt.
https://github.com/comfyanonymous/ComfyUI/blob/fc657f471a29d07696ca16b566000e8e555d67d1/comfy_execution/graph.py#L22
"""
if self._prompt_nodes is None:
self._prompt_nodes = []
if self._dynprompt:
all_ids = self._dynprompt.all_node_ids()
self._prompt_nodes = [{'id': k} | {**self._dynprompt.get_node(k)} for k in all_ids]
return self._prompt_nodes
def _get_prompt_node(self):
if self._prompt_nodes is None:
self._prompt_node = [n for n in self._get_prompt_nodes() if n['id'] == self._unique_id][0]
return self._prompt_node
def _get_nodes(self, node_id: Union[int, str, re.Pattern, None] = None) -> list[Any]:
"""Get a list of the nodes that match the node_id, or all the nodes in the prompt."""
nodes = self._get_prompt_nodes().copy()
if not node_id:
return nodes
if isinstance(node_id, re.Pattern):
found = [n for n in nodes if re.search(node_id, get_dict_value(n, '_meta.title', ''))]
else:
node_id = str(node_id)
found = None
if re.match(r'\d+$', node_id):
found = [n for n in nodes if node_id == n['id']]
if not found:
found = [n for n in nodes if node_id == get_dict_value(n, '_meta.title', '')]
return found
def _get_node(self, node_id: Union[int, str, re.Pattern, None] = None) -> Union[Any, None]:
"""Returns a prompt-node from the hidden prompt."""
if node_id is None:
return self._get_prompt_node()
nodes = self._get_nodes(node_id)
if nodes and len(nodes) > 1:
log_node_warn(_NODE_NAME, f"More than one node found for '{node_id}'. Returning first.")
return nodes[0] if nodes else None
def _get_input_node(self, input_name, node=None):
"""Gets the (non-muted) node of an input connection from a node (default to the power puter)."""
node = node if node else self._get_prompt_node()
try:
connected_node_id = node['inputs'][input_name][0]
return [n for n in self._get_prompt_nodes() if n['id'] == connected_node_id][0]
except (TypeError, IndexError, KeyError):
log_node_warn(_NODE_NAME, f'No input node found for "{input_name}". ')
return None
def _eval_statement(self, stmt: ast.AST, ctx: dict, prev_stmt: Union[ast.AST, None] = None):
"""Evaluates an ast.stmt."""
if '__returned__' in ctx:
return ctx['__returned__']
# print('\n\n----: _eval_statement')
# print(type(stmt))
# print(ctx)
if isinstance(stmt, (ast.FormattedValue, ast.Expr)):
return self._eval_statement(stmt.value, ctx=ctx)
if isinstance(stmt, AstNumConstantClasses):
return stmt.n if hasattr(stmt, "n") else stmt.value
if isinstance(stmt, ast.BinOp):
left = self._eval_statement(stmt.left, ctx=ctx)
right = self._eval_statement(stmt.right, ctx=ctx)
return _OPERATORS[type(stmt.op)](left, right)
if isinstance(stmt, ast.BoolOp):
is_and = isinstance(stmt.op, ast.And)
is_or = isinstance(stmt.op, ast.Or)
stmt_value_eval = None
for stmt_value in stmt.values:
stmt_value_eval = self._eval_statement(stmt_value, ctx=ctx)
# If we're an and operator and have a falsyt value, then we stop and return. Likewise, if
# we're an or operator and have a truthy value, we can stop and return.
if (is_and and not stmt_value_eval) or (is_or and stmt_value_eval):
return stmt_value_eval
# Always return the last if we made it here w/o success.
return stmt_value_eval
if isinstance(stmt, ast.UnaryOp):
return _OPERATORS[type(stmt.op)](self._eval_statement(stmt.operand, ctx=ctx))
if isinstance(stmt, (ast.Attribute, ast.Subscript)):
# Like: node(14).inputs.sampler_name (Attribute)
# Like: node(14)['inputs']['sampler_name'] (Subscript)
item = self._eval_statement(stmt.value, ctx=ctx)
attr = None
# if hasattr(stmt, 'attr'):
if isinstance(stmt, ast.Attribute):
attr = stmt.attr
else:
# Slice could be a name or a constant; evaluate it
attr = self._eval_statement(stmt.slice, ctx=ctx)
# Check if we're blocking access to this attribute/method on this item type.
for typ, names in _BLOCKED_METHODS_OR_ATTRS.items():
if isinstance(item, typ) and isinstance(attr, str) and attr in names:
raise ValueError(f'Disallowed access to "{attr}" for type {typ}.')
try:
val = item[attr]
except (TypeError, IndexError, KeyError):
try:
val = getattr(item, attr)
except AttributeError:
# If we're a dict, then just return None instead of error; saves time.
if isinstance(item, dict):
# Any special cases in the _SPECIAL_FUNCTIONS
class_type = get_dict_value(item, "class_type")
if class_type in _SPECIAL_FUNCTIONS and attr in _SPECIAL_FUNCTIONS[class_type]:
val = _SPECIAL_FUNCTIONS[class_type][attr]
# If our previous statment was a Call, then send back a tuple of the callable and
# the evaluated item, and it will make the call; perhaps also adding other arguments
# only it knows about.
if isinstance(prev_stmt, ast.Call):
return (val, item)
val = val(item)
else:
val = None
else:
raise
return val
if isinstance(stmt, (ast.List, ast.Tuple)):
value = []
for elt in stmt.elts:
value.append(self._eval_statement(elt, ctx=ctx))
return tuple(value) if isinstance(stmt, ast.Tuple) else value
if isinstance(stmt, ast.Dict):
the_dict = {}
if stmt.keys:
if len(stmt.keys) != len(stmt.values):
raise ValueError('Expected same number of keys as values for dict.')
for i, k in enumerate(stmt.keys):
item_key = self._eval_statement(k, ctx=ctx)
item_value = self._eval_statement(stmt.values[i], ctx=ctx)
the_dict[item_key] = item_value
return the_dict
# f-strings: https://www.basicexamples.com/example/python/ast-JoinedStr
# Note, this will str() all evaluated items in the fstrings, and doesn't handle f-string
# directives, like padding, etc.
if isinstance(stmt, ast.JoinedStr):
vals = [str(self._eval_statement(v, ctx=ctx)) for v in stmt.values]
val = ''.join(vals)
return val
if isinstance(stmt, ast.Slice):
if not stmt.lower or not stmt.upper:
raise ValueError('Unhandled Slice w/o lower or upper.')
slice_lower = self._eval_statement(stmt.lower, ctx=ctx)
slice_upper = self._eval_statement(stmt.upper, ctx=ctx)
if stmt.step:
slice_step = self._eval_statement(stmt.step, ctx=ctx)
return slice(slice_lower, slice_upper, slice_step)
return slice(slice_lower, slice_upper)
if isinstance(stmt, ast.Name):
if stmt.id in ctx:
val = ctx[stmt.id]
return val
if stmt.id in _BUILT_INS:
val = _BUILT_INS[stmt.id]
return val
raise NameError(f"Name not found: {stmt.id}")
if isinstance(stmt, ast.For):
for_iter = self._eval_statement(stmt.iter, ctx=ctx)
for item in for_iter:
# Set the for var(s)
if isinstance(stmt.target, ast.Name):
ctx[stmt.target.id] = item
elif isinstance(stmt.target, ast.Tuple): # dict, like `for k, v in d.entries()`
for i, elt in enumerate(stmt.target.elts):
ctx[elt.id] = item[i]
bodies = stmt.body if isinstance(stmt.body, list) else [stmt.body]
breaked = False
for body in bodies:
# Catch any breaks or continues and handle inside the loop normally.
try:
value = self._eval_statement(body, ctx=ctx)
except (LoopBreak, LoopContinue) as e:
breaked = isinstance(e, LoopBreak)
break
if breaked:
break
return None
if isinstance(stmt, ast.While):
while self._eval_statement(stmt.test, ctx=ctx):
bodies = stmt.body if isinstance(stmt.body, list) else [stmt.body]
breaked = False
for body in bodies:
# Catch any breaks or continues and handle inside the loop normally.
try:
value = self._eval_statement(body, ctx=ctx)
except (LoopBreak, LoopContinue) as e:
breaked = isinstance(e, LoopBreak)
break
if breaked:
break
return None
if isinstance(stmt, ast.ListComp):
# Like: [v.lora for name, v in node(19).inputs.items() if name.startswith('lora_')]
# Like: [v.lower() for v in lora_list]
# Like: [v for v in l if v.startswith('B')]
# Like: [v.lower() for v in l if v.startswith('B') or v.startswith('F')]
# ---
# Like: [l for n in nodes(re('Loras')).values() if (l := n.loras)]
final_list = []
gen_ctx = {**ctx}
generators = [*stmt.generators]
def handle_gen(generators: list[ast.comprehension]):
gen = generators.pop(0)
if isinstance(gen.target, ast.Name):
gen_ctx[gen.target.id] = None
elif isinstance(gen.target, ast.Tuple): # dict, like `for k, v in d.entries()`
for elt in gen.target.elts:
gen_ctx[elt.id] = None
else:
raise ValueError('Na')
gen_iters = None
# A call, like my_dct.items(), or a named ctx list
if isinstance(gen.iter, ast.Call):
gen_iters = self._eval_statement(gen.iter, ctx=gen_ctx)
elif isinstance(gen.iter, (ast.Name, ast.Attribute, ast.List, ast.Tuple)):
gen_iters = self._eval_statement(gen.iter, ctx=gen_ctx)
if not isinstance(gen_iters, Iterable):
raise ValueError('No iteraors found for list comprehension')
for gen_iter in gen_iters:
if_ctx = {**gen_ctx}
if isinstance(gen.target, ast.Tuple): # dict, like `for k, v in d.entries()`
for i, elt in enumerate(gen.target.elts):
if_ctx[elt.id] = gen_iter[i]
else:
if_ctx[gen.target.id] = gen_iter
good = True
for ifcall in gen.ifs:
if not self._eval_statement(ifcall, ctx=if_ctx):
good = False
break
if not good:
continue
gen_ctx.update(if_ctx)
if len(generators):
handle_gen(generators)
else:
final_list.append(self._eval_statement(stmt.elt, gen_ctx))
generators.insert(0, gen)
handle_gen(generators)
return final_list
if isinstance(stmt, ast.Call):
call = None
args = []
kwargs = {}
if isinstance(stmt.func, ast.Attribute):
call = self._eval_statement(stmt.func, prev_stmt=stmt, ctx=ctx)
if isinstance(call, tuple):
args.append(call[1])
call = call[0]
if not call:
raise ValueError(f'No call for ast.Call {stmt.func}')
name = ''
if isinstance(stmt.func, ast.Name):
name = stmt.func.id
if name in _BUILT_INS:
call = _BUILT_INS[name]
if isinstance(call, str) and call.startswith(_BUILTIN_FN_PREFIX):
fn = _get_built_in_fn_by_key(call)
call = fn.call
if isinstance(call, str):
call = getattr(self, call)
num_args = len(stmt.args)
if num_args < fn.args[0] or (fn.args[1] is not None and num_args > fn.args[1]):
toErr = " or more" if fn.args[1] is None else f" to {fn.args[1]}"
raise SyntaxError(f"Invalid function call: {fn.name} requires {fn.args[0]}{toErr} args")
if not call:
raise ValueError(f'No call for ast.Call {name}')
for arg in stmt.args:
args.append(self._eval_statement(arg, ctx=ctx))
for kwarg in stmt.keywords:
kwargs[kwarg.arg] = self._eval_statement(kwarg.value, ctx=ctx)
return call(*args, **kwargs)
if isinstance(stmt, ast.Compare):
l = self._eval_statement(stmt.left, ctx=ctx)
r = self._eval_statement(stmt.comparators[0], ctx=ctx)
if isinstance(stmt.ops[0], ast.Eq):
return 1 if l == r else 0
if isinstance(stmt.ops[0], ast.NotEq):
return 1 if l != r else 0
if isinstance(stmt.ops[0], ast.Gt):
return 1 if l > r else 0
if isinstance(stmt.ops[0], ast.GtE):
return 1 if l >= r else 0
if isinstance(stmt.ops[0], ast.Lt):
return 1 if l < r else 0
if isinstance(stmt.ops[0], ast.LtE):
return 1 if l <= r else 0
if isinstance(stmt.ops[0], ast.In):
return 1 if l in r else 0
if isinstance(stmt.ops[0], ast.Is):
return 1 if l is r else 0
if isinstance(stmt.ops[0], ast.IsNot):
return 1 if l is not r else 0
raise NotImplementedError("Operator " + stmt.ops[0].__class__.__name__ + " not supported.")
if isinstance(stmt, (ast.If, ast.IfExp)):
value = self._eval_statement(stmt.test, ctx=ctx)
if value:
# ast.If is a list, ast.IfExp is an object.
bodies = stmt.body if isinstance(stmt.body, list) else [stmt.body]
for body in bodies:
value = self._eval_statement(body, ctx=ctx)
elif stmt.orelse:
# ast.If is a list, ast.IfExp is an object. TBH, I don't know why the If is a list, it's
# only ever one item AFAICT.
orelses = stmt.orelse if isinstance(stmt.orelse, list) else [stmt.orelse]
for orelse in orelses:
value = self._eval_statement(orelse, ctx=ctx)
return value
# Assign a variable and add it to our ctx.
if isinstance(stmt, (ast.Assign, ast.AugAssign)):
if isinstance(stmt, ast.AugAssign):
left = self._eval_statement(stmt.target, ctx=ctx)
right = self._eval_statement(stmt.value, ctx=ctx)
value = _OPERATORS[type(stmt.op)](left, right)
target = stmt.target
else:
value = self._eval_statement(stmt.value, ctx=ctx)
if len(stmt.targets) != 1:
raise ValueError('Expected length of assign targets to be 1')
target = stmt.targets[0]
if isinstance(target, ast.Tuple): # like `a, z = (1,2)` (ast.Assign only)
for i, elt in enumerate(target.elts):
ctx[elt.id] = value[i]
elif isinstance(target, ast.Name): # like `a = 1``
ctx[target.id] = value
elif isinstance(target, ast.Subscript) and isinstance(target.value, ast.Name): # `a[0] = 1`
ctx[target.value.id][self._eval_statement(target.slice, ctx=ctx)] = value
else:
raise ValueError('Unhandled target type for Assign.')
return value
# For assigning a var in a list comprehension.
# Like [name for node in node_list if (name := node.name)]
if isinstance(stmt, ast.NamedExpr):
value = self._eval_statement(stmt.value, ctx=ctx)
ctx[stmt.target.id] = value
return value
if isinstance(stmt, ast.Return):
if stmt.value is None:
value = None
else:
value = self._eval_statement(stmt.value, ctx=ctx)
# Mark that we have a return value, as we may be deeper in evaluation, like going through an
# if condition's body.
ctx['__returned__'] = value
return value
# Raise an error for break or continue, which should be caught and handled inside of loops,
# otherwise the error will be raised (which is desired when used outside of a loop).
if isinstance(stmt, ast.Break):
raise LoopBreak()
if isinstance(stmt, ast.Continue):
raise LoopContinue()
# Literally nothing.
if isinstance(stmt, ast.Pass):
return None
raise TypeError(stmt)
+90
View File
@@ -0,0 +1,90 @@
import os
import re
import json
from .utils import set_dict_value
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
_FILE_PY_PROJECT = os.path.join(_THIS_DIR, '..', 'pyproject.toml')
def read_pyproject():
"""Reads the pyproject.toml file"""
data = {}
last_key = ''
lines = []
# I'd like to use tomllib/tomli, but I'd much rather not introduce dependencies since I've yet to
# need to and not everyone may have 3.11. We've got a controlled config file anyway.
with open(_FILE_PY_PROJECT, "r", encoding='utf-8') as f:
lines = f.readlines()
for line in lines:
line = line.strip()
if re.match(r'\[([^\]]+)\]$', line):
last_key = line[1:-1]
set_dict_value(data, last_key, data[last_key] if last_key in data else {})
continue
value_matches = re.match(r'^([^\s\=]+)\s*=\s*(.*)$', line)
if value_matches:
try:
set_dict_value(data, f'{last_key}.{value_matches[1]}', json.loads(value_matches[2]))
except json.decoder.JSONDecodeError:
# We don't handle multiline arrays or curly brackets; that's ok, we know the file.
pass
return data
_DATA = read_pyproject()
# We would want these to fail if they don't exist, so assume they do.
VERSION: str = _DATA['project']['version']
NAME: str = _DATA['project']['name']
LOGO_URL: str = _DATA['tool']['comfy']['Icon']
if not LOGO_URL.endswith('.svg'):
raise ValueError('Bad logo url.')
LOGO_SVG = None
def _local_logo_svg():
"""The logo bundled with this repository, used when the remote one is unavailable."""
import os
try:
path = os.path.join(_THIS_DIR, '..', 'web', 'common', 'media', 'rgthree.svg')
with open(path, 'r', encoding='utf-8') as f:
return f.read()
except Exception:
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"></svg>'
async def get_logo_svg():
import aiohttp
global LOGO_SVG
if LOGO_SVG is not None:
return LOGO_SVG
# Fetch the logo so we have any updated markup.
try:
async with aiohttp.ClientSession(
trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=True)
) as session:
headers = {
"user-agent": f"rgthree-comfy/{VERSION}",
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Expires': '0'
}
async with session.get(LOGO_URL, headers=headers) as resp:
fetched = await resp.text()
# The response is not guaranteed to be our SVG: a proxy, a captive portal or a
# CDN error page answers with HTML (Cloudflare pages contain `{` in their CSS,
# which later blows up `str.format` in the route). Only accept real SVG markup
# and otherwise fall back to the bundled logo below.
if '<svg' not in fetched.lower():
raise ValueError('remote logo is not SVG')
LOGO_SVG = fetched
LOGO_SVG = re.sub(r'(id="bg".*fill=)"[^\"]+"', r'\1"{bg}"', LOGO_SVG)
LOGO_SVG = re.sub(r'(id="fg".*fill=)"[^\"]+"', r'\1"{fg}"', LOGO_SVG)
except Exception:
LOGO_SVG = _local_logo_svg()
return LOGO_SVG
+55 -41
View File
@@ -1,49 +1,63 @@
from nodes import EmptyLatentImage
from .constants import get_category, get_name
class RgthreeSDXLEmptyLatentImage:
NAME = get_name('SDXL Empty Latent Image')
CATEGORY = get_category()
NAME = get_name('SDXL Empty Latent Image')
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"dimensions" : ([
# 'Custom',
'1536 x 640 (landscape)',
'1344 x 768 (landscape)',
'1216 x 832 (landscape)',
'1152 x 896 (landscape)',
'1024 x 1024 (square)',
' 896 x 1152 (portrait)',
' 832 x 1216 (portrait)',
' 768 x 1344 (portrait)',
' 640 x 1536 (portrait)',
], {"default": '1024 x 1024 (square)'}),
"clip_scale": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 10.0, "step": .5}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64}),
},
"optional": {
# "custom_width": ("INT", {"min": 1, "max": MAX_RESOLUTION, "step": 64}),
# "custom_height": ("INT", {"min": 1, "max": MAX_RESOLUTION, "step": 64}),
}
}
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {
"dimensions": (
[
# 'Custom',
'1536 x 640 (landscape)',
'1344 x 768 (landscape)',
'1216 x 832 (landscape)',
'1152 x 896 (landscape)',
'1024 x 1024 (square)',
' 896 x 1152 (portrait)',
' 832 x 1216 (portrait)',
' 768 x 1344 (portrait)',
' 640 x 1536 (portrait)',
],
{
"default": '1024 x 1024 (square)'
}),
"clip_scale": ("FLOAT", {
"default": 2.0,
"min": 1.0,
"max": 10.0,
"step": .5
}),
"batch_size": ("INT", {
"default": 1,
"min": 1,
"max": 64
}),
},
# "optional": {
# "custom_width": ("INT", {"min": 1, "max": MAX_RESOLUTION, "step": 64}),
# "custom_height": ("INT", {"min": 1, "max": MAX_RESOLUTION, "step": 64}),
# }
}
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("LATENT", "CLIP_WIDTH", "CLIP_HEIGHT")
FUNCTION = "generate"
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("LATENT", "CLIP_WIDTH", "CLIP_HEIGHT")
FUNCTION = "generate"
# def generate(self, dimensions, clip_scale, batch_size, custom_width=1024, custom_height=1024):
def generate(self, dimensions, clip_scale, batch_size):
# if dimensions == 'Custom':
# width = custom_width
# height = custom_height
# else:
if True:
result = [x.strip() for x in dimensions.split('x')]
width = int(result[0])
height = int(result[1].split(' ')[0])
latent = EmptyLatentImage().generate(width, height, batch_size)[0]
return (latent, int(width * clip_scale), int(height * clip_scale),)
def generate(self, dimensions, clip_scale, batch_size):
"""Generates the latent and exposes the clip_width and clip_height"""
if True:
result = [x.strip() for x in dimensions.split('x')]
width = int(result[0])
height = int(result[1].split(' ')[0])
latent = EmptyLatentImage().generate(width, height, batch_size)[0]
return (
latent,
int(width * clip_scale),
int(height * clip_scale),
)
+178
View File
@@ -0,0 +1,178 @@
import os
import re
from nodes import MAX_RESOLUTION
from comfy_extras.nodes_clip_sdxl import CLIPTextEncodeSDXL
from .log import log_node_warn, log_node_info, log_node_success
from .constants import get_category, get_name
from .power_prompt_utils import get_and_strip_loras
from nodes import LoraLoader, CLIPTextEncode
import folder_paths
NODE_NAME = get_name('SDXL Power Prompt - Positive')
class RgthreeSDXLPowerPromptPositive:
"""The Power Prompt for positive conditioning."""
NAME = NODE_NAME
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
# Removed Saved Prompts feature; No sure it worked any longer. UI should fail gracefully,
# TODO: Rip out saved prompt input data
SAVED_PROMPTS_FILES=[]
SAVED_PROMPTS_CONTENT=[]
return {
'required': {
'prompt_g': ('STRING', {
'multiline': True,
'dynamicPrompts': True
}),
'prompt_l': ('STRING', {
'multiline': True,
'dynamicPrompts': True
}),
},
'optional': {
"opt_model": ("MODEL",),
"opt_clip": ("CLIP",),
"opt_clip_width": ("INT", {
"forceInput": True,
"default": 1024.0,
"min": 0,
"max": MAX_RESOLUTION
}),
"opt_clip_height": ("INT", {
"forceInput": True,
"default": 1024.0,
"min": 0,
"max": MAX_RESOLUTION
}),
'insert_lora': (['CHOOSE', 'DISABLE LORAS'] +
[os.path.splitext(x)[0] for x in folder_paths.get_filename_list('loras')],),
'insert_embedding': ([
'CHOOSE',
] + [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('embeddings')],),
'insert_saved': ([
'CHOOSE',
] + SAVED_PROMPTS_FILES,),
# We'll hide these in the UI for now.
"target_width": ("INT", {
"default": -1,
"min": -1,
"max": MAX_RESOLUTION
}),
"target_height": ("INT", {
"default": -1,
"min": -1,
"max": MAX_RESOLUTION
}),
"crop_width": ("INT", {
"default": -1,
"min": -1,
"max": MAX_RESOLUTION
}),
"crop_height": ("INT", {
"default": -1,
"min": -1,
"max": MAX_RESOLUTION
}),
},
'hidden': {
'values_insert_saved': (['CHOOSE'] + SAVED_PROMPTS_CONTENT,),
}
}
RETURN_TYPES = ('CONDITIONING', 'MODEL', 'CLIP', 'STRING', 'STRING')
RETURN_NAMES = ('CONDITIONING', 'MODEL', 'CLIP', 'TEXT_G', 'TEXT_L')
FUNCTION = 'main'
def main(self,
prompt_g,
prompt_l,
opt_model=None,
opt_clip=None,
opt_clip_width=None,
opt_clip_height=None,
insert_lora=None,
insert_embedding=None,
insert_saved=None,
target_width=-1,
target_height=-1,
crop_width=-1,
crop_height=-1,
values_insert_saved=None):
if insert_lora == 'DISABLE LORAS':
prompt_g, loras_g, _skipped, _unfound = get_and_strip_loras(prompt_g,
True,
log_node=self.NAME)
prompt_l, loras_l, _skipped, _unfound = get_and_strip_loras(prompt_l,
True,
log_node=self.NAME)
loras = loras_g + loras_l
log_node_info(
NODE_NAME,
f'Disabling all found loras ({len(loras)}) and stripping lora tags for TEXT output.')
elif opt_model is not None and opt_clip is not None:
prompt_g, loras_g, _skipped, _unfound = get_and_strip_loras(prompt_g, log_node=self.NAME)
prompt_l, loras_l, _skipped, _unfound = get_and_strip_loras(prompt_l, log_node=self.NAME)
loras = loras_g + loras_l
if len(loras) > 0:
for lora in loras:
opt_model, opt_clip = LoraLoader().load_lora(opt_model, opt_clip, lora['lora'],
lora['strength'], lora['strength'])
log_node_success(NODE_NAME, f'Loaded "{lora["lora"]}" from prompt')
log_node_info(NODE_NAME, f'{len(loras)} Loras processed; stripping tags for TEXT output.')
elif '<lora:' in prompt_g or '<lora:' in prompt_l:
_prompt_g, loras_g, _skipped, _unfound = get_and_strip_loras(prompt_g,
True,
log_node=self.NAME)
_prompt_l, loras_l, _skipped, _unfound = get_and_strip_loras(prompt_l,
True,
log_node=self.NAME)
loras = loras_g + loras_l
if len(loras):
log_node_warn(
NODE_NAME, f'Found {len(loras)} lora tags in prompt but model & clip were not supplied!')
log_node_info(NODE_NAME, 'Loras not processed, keeping for TEXT output.')
conditioning = self.get_conditioning(prompt_g, prompt_l, opt_clip, opt_clip_width,
opt_clip_height, target_width, target_height, crop_width,
crop_height)
return (conditioning, opt_model, opt_clip, prompt_g, prompt_l)
def get_conditioning(self, prompt_g, prompt_l, opt_clip, opt_clip_width, opt_clip_height,
target_width, target_height, crop_width, crop_height):
"""Checks the inputs and gets the conditioning."""
conditioning = None
if opt_clip is not None:
do_regular_clip_text_encode = opt_clip_width and opt_clip_height
if do_regular_clip_text_encode:
target_width = target_width if target_width and target_width > 0 else opt_clip_width
target_height = target_height if target_height and target_height > 0 else opt_clip_height
crop_width = crop_width if crop_width and crop_width > 0 else 0
crop_height = crop_height if crop_height and crop_height > 0 else 0
try:
conditioning = CLIPTextEncodeSDXL().encode(opt_clip, opt_clip_width, opt_clip_height,
crop_width, crop_height, target_width,
target_height, prompt_g, prompt_l)[0]
except Exception:
do_regular_clip_text_encode = True
log_node_info(
self.NAME,
'Exception while attempting to CLIPTextEncodeSDXL, will fall back to standard encoding.'
)
else:
log_node_info(
self.NAME,
'CLIP supplied, but not CLIP_WIDTH and CLIP_HEIGHT. Text encoding will use standard ' +
'encoding with prompt_g and prompt_l concatenated.')
if not do_regular_clip_text_encode:
conditioning = CLIPTextEncode().encode(
opt_clip, f'{prompt_g if prompt_g else ""}\n{prompt_l if prompt_l else ""}')[0]
return conditioning
+106
View File
@@ -0,0 +1,106 @@
"""A simpler SDXL Power Prompt that doesn't load Loras, like for negative."""
import os
import re
import folder_paths
from nodes import MAX_RESOLUTION, LoraLoader
from comfy_extras.nodes_clip_sdxl import CLIPTextEncodeSDXL
from .sdxl_power_prompt_postive import RgthreeSDXLPowerPromptPositive
from .log import log_node_warn, log_node_info, log_node_success
from .constants import get_category, get_name
NODE_NAME = get_name('SDXL Power Prompt - Simple / Negative')
class RgthreeSDXLPowerPromptSimple(RgthreeSDXLPowerPromptPositive):
"""A simpler SDXL Power Prompt that doesn't handle Loras."""
NAME = NODE_NAME
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
# Removed Saved Prompts feature; No sure it worked any longer. UI should fail gracefully,
# TODO: Rip out saved prompt input data
SAVED_PROMPTS_FILES=[]
SAVED_PROMPTS_CONTENT=[]
return {
'required': {
'prompt_g': ('STRING', {
'multiline': True,
'dynamicPrompts': True
}),
'prompt_l': ('STRING', {
'multiline': True,
'dynamicPrompts': True
}),
},
'optional': {
"opt_clip": ("CLIP",),
"opt_clip_width": ("INT", {
"forceInput": True,
"default": 1024.0,
"min": 0,
"max": MAX_RESOLUTION
}),
"opt_clip_height": ("INT", {
"forceInput": True,
"default": 1024.0,
"min": 0,
"max": MAX_RESOLUTION
}),
'insert_embedding': ([
'CHOOSE',
] + [os.path.splitext(x)[0] for x in folder_paths.get_filename_list('embeddings')],),
'insert_saved': ([
'CHOOSE',
] + SAVED_PROMPTS_FILES,),
# We'll hide these in the UI for now.
"target_width": ("INT", {
"default": -1,
"min": -1,
"max": MAX_RESOLUTION
}),
"target_height": ("INT", {
"default": -1,
"min": -1,
"max": MAX_RESOLUTION
}),
"crop_width": ("INT", {
"default": -1,
"min": -1,
"max": MAX_RESOLUTION
}),
"crop_height": ("INT", {
"default": -1,
"min": -1,
"max": MAX_RESOLUTION
}),
},
'hidden': {
'values_insert_saved': (['CHOOSE'] + SAVED_PROMPTS_CONTENT,),
}
}
RETURN_TYPES = ('CONDITIONING', 'STRING', 'STRING')
RETURN_NAMES = ('CONDITIONING', 'TEXT_G', 'TEXT_L')
FUNCTION = 'main'
def main(self,
prompt_g,
prompt_l,
opt_clip=None,
opt_clip_width=None,
opt_clip_height=None,
insert_embedding=None,
insert_saved=None,
target_width=-1,
target_height=-1,
crop_width=-1,
crop_height=-1,
values_insert_saved=None):
conditioning = self.get_conditioning(prompt_g, prompt_l, opt_clip, opt_clip_width,
opt_clip_height, target_width, target_height, crop_width, crop_height)
return (conditioning, prompt_g, prompt_l)
+127 -14
View File
@@ -1,22 +1,135 @@
"""See node."""
import random
from datetime import datetime
from .utils_graph import get_worflow_node
from .constants import get_category, get_name
from .log import log_node_warn, log_node_info
# Some extension must be setting a seed as server-generated seeds were not random. We'll set a new
# seed and use that state going forward.
initial_random_state = random.getstate()
random.seed(datetime.now().timestamp())
rgthree_seed_random_state = random.getstate()
random.setstate(initial_random_state)
def new_random_seed():
""" Gets a new random seed from the rgthree_seed_random_state and resetting the previous state."""
global rgthree_seed_random_state
prev_random_state = random.getstate()
random.setstate(rgthree_seed_random_state)
seed = random.randint(1, 1125899906842624)
rgthree_seed_random_state = random.getstate()
random.setstate(prev_random_state)
return seed
class RgthreeSeed:
"""Seed node."""
NAME = get_name('Seed')
CATEGORY = get_category()
NAME = get_name('Seed')
CATEGORY = get_category()
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"seed": ("INT", {"default": 0, "min": -1125899906842624, "max": 1125899906842624}),
},
}
@classmethod
def INPUT_TYPES(cls): # pylint: disable = invalid-name, missing-function-docstring
return {
"required": {
"seed": ("INT", {
"default": 0,
"min": -1125899906842624,
"max": 1125899906842624
}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("SEED",)
FUNCTION = "main"
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("SEED",)
FUNCTION = "main"
def main(self, seed=0):
return (seed,)
@classmethod
def IS_CHANGED(cls, seed, prompt=None, extra_pnginfo=None, unique_id=None):
"""Forces a changed state if we happen to get a special seed, as if from the API directly."""
if seed in (-1, -2, -3):
# This isn't used, but a different value than previous will force it to be "changed"
return new_random_seed()
return seed
def main(self, seed=0, prompt=None, extra_pnginfo=None, unique_id=None):
"""Returns the passed seed on execution."""
# We generate random seeds on the frontend in the seed node before sending the workflow in for
# many reasons. However, if we want to use this in an API call without changing the seed before
# sending, then users _could_ pass in "-1" and get a random seed used and added to the metadata.
# Though, this should likely be discouraged for several reasons (thus, a lot of logging).
if seed in (-1, -2, -3):
log_node_warn(
self.NAME,
f'Got "{seed}" as passed seed. ' +
'This shouldn\'t happen when queueing from the ComfyUI frontend.',
msg_color="YELLOW"
)
if seed in (-2, -3):
log_node_warn(
self.NAME,
f'Cannot {"increment" if seed == -2 else "decrement"} seed from ' +
'server, but will generate a new random seed.',
msg_color="YELLOW"
)
original_seed = seed
seed = new_random_seed()
log_node_info(self.NAME, f'Server-generated random seed {seed} and saving to workflow.')
log_node_warn(
self.NAME,
f'NOTE: Re-queues passing in "{seed}" and server-generated random seed won\'t be cached.',
msg_color="YELLOW"
)
if unique_id is None:
log_node_warn(
self.NAME, 'Cannot save server-generated seed to image metadata because ' +
'the node\'s id was not provided.'
)
else:
if extra_pnginfo is None:
log_node_warn(
self.NAME, 'Cannot save server-generated seed to image workflow ' +
'metadata because workflow was not provided.'
)
else:
log_node_info(self.NAME, f'Looking for Seed node with id "{unique_id}"')
workflow_node = get_worflow_node(extra_pnginfo, str(unique_id))
if workflow_node is None or 'widgets_values' not in workflow_node:
log_node_warn(
self.NAME, 'Cannot save server-generated seed to image workflow ' +
'metadata because node was not found in the provided workflow.'
)
else:
for index, widget_value in enumerate(workflow_node['widgets_values']):
if widget_value == original_seed:
workflow_node['widgets_values'][index] = seed
if prompt is None:
log_node_warn(
self.NAME, 'Cannot save server-generated seed to image API prompt ' +
'metadata because prompt was not provided.'
)
else:
prompt_node = prompt[str(unique_id)]
if prompt_node is None or 'inputs' not in prompt_node or 'seed' not in prompt_node[
'inputs']:
log_node_warn(
self.NAME, 'Cannot save server-generated seed to image workflow ' +
'metadata because node was not found in the provided workflow.'
)
else:
prompt_node['inputs']['seed'] = seed
return (seed,)
+46
View File
@@ -0,0 +1,46 @@
import os
from aiohttp import web
from server import PromptServer
from ..config import get_config_value
from ..log import log, log_known_message
from .utils_server import set_default_page_resources, set_default_page_routes, get_param
from .routes_config import *
from .routes_model_info import *
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
DIR_EXTENSIONS = os.path.abspath(os.path.join(THIS_DIR, '..', '..', '..'))
routes = PromptServer.instance.routes
# Sometimes other pages (link_fixer, etc.) may want to import JS from the comfyui
# directory. To allows TS to resolve like '../comfyui/file.js', we'll also resolve any module HTTP
# to these routes.
set_default_page_resources("comfyui", routes)
set_default_page_resources("common", routes)
set_default_page_resources("lib", routes)
set_default_page_routes("link_fixer", routes)
if get_config_value('unreleased.models_page.enabled') is True:
set_default_page_routes("models", routes)
@routes.get('/rgthree/api/print')
async def api_print(request):
"""Logs a user message to the terminal."""
message_type = get_param(request, 'type')
if log_known_message(message_type) is False:
log("Unknown log type from api", prefix="rgthree-comfy", color="YELLOW")
return web.json_response({})
@routes.get('/rgthree/api/incompatible-extensions')
async def api_incompatible_extensions(request):
"""Checks if the extensions folder contains any bad folders known to cause conflict."""
data = {'extensions': []}
# There's probably a better way to check, but for now we'll assume we can check the parent
# directory for the existance of bad folders.
if os.path.isdir(os.path.join(DIR_EXTENSIONS, 'ComfyUI_Swwan')):
data['extensions'].append('ComfyUI_Swwan')
return web.json_response(data)
+72
View File
@@ -0,0 +1,72 @@
import json
import re
from aiohttp import web
from server import PromptServer
from ..pyproject import get_logo_svg
from .utils_server import is_param_truthy, get_param
from ..config import get_config, set_user_config, refresh_config
routes = PromptServer.instance.routes
@routes.get('/rgthree/config.js')
def api_get_user_config_file(request):
""" Returns the user configuration as a javascript file. """
data_str = json.dumps(get_config(), sort_keys=True, indent=2, separators=(",", ": "))
text = f'export const rgthreeConfig = {data_str}'
return web.Response(text=text, content_type='application/javascript')
@routes.get('/rgthree/api/config')
def api_get_user_config(request):
""" Returns the user configuration. """
if is_param_truthy(request, 'refresh'):
refresh_config()
return web.json_response(get_config())
@routes.post('/rgthree/api/config')
async def api_set_user_config(request):
""" Returns the user configuration. """
post = await request.post()
data = json.loads(post.get("json"))
set_user_config(data)
return web.json_response({"status": "ok"})
@routes.get('/rgthree/logo.svg')
async def get_logo(request, as_markup=False):
""" Returns the rgthree logo with color config. """
bg = get_param(request, 'bg', 'transparent')
fg = get_param(request, 'fg', '#111111')
w = get_param(request, 'w')
h = get_param(request, 'h')
css_class = get_param(request, 'cssClass')
svg = await get_logo_svg()
# `str.format` raises on any stray `{` in the markup, which turns a cached
# non-SVG response into a 500 for the whole route. Substitute the two
# placeholders directly, and never serve non-SVG content.
if '<svg' not in svg.lower():
svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"></svg>'
resp = svg.replace('{bg}', bg).replace('{fg}', fg)
if w is not None:
resp = re.sub(r'(<svg[^\>]*?)width="[^\"]+"', r'\1', resp)
if str(w).isnumeric():
resp = re.sub(r'<svg', f'<svg width="{w}"', resp)
if h is not None:
resp = re.sub(r'(<svg[^\>]*?)height="[^\"]+"', r'\1', resp)
if str(h).isnumeric():
resp = re.sub(r'<svg', f'<svg height="{h}"', resp)
if css_class is not None:
resp = re.sub(r'<svg', f'<svg class="{css_class}"', resp)
if as_markup:
resp = re.sub(r'^.*?<svg', r'<svg', resp, flags=re.DOTALL)
return web.Response(text=resp, content_type='image/svg+xml')
@routes.get('/rgthree/logo_markup.svg')
async def get_logo_markup(request):
""" Returns the rgthree logo svg markup (no doctype) with options. """
return await get_logo(request, as_markup=True)
+199
View File
@@ -0,0 +1,199 @@
import os
import json
from aiohttp import web
from ..log import log
from server import PromptServer
import folder_paths
from ..utils import abspath, path_exists
from .utils_server import get_param, is_param_falsy
from .utils_info import delete_model_info, get_model_info, set_model_info_partial, get_file_info
routes = PromptServer.instance.routes
def _check_valid_model_type(request):
model_type = request.match_info['type']
if model_type not in ['loras', 'checkpoints']:
return web.json_response({'status': 404, 'error': f'Invalid model type: {model_type}'})
return None
@routes.get('/rgthree/api/{type}')
async def api_get_models_list(request):
"""Returns a list of model types from user configuration.
By default, a list of filenames are provided. If `format=details` is specified, a list of objects
with additional _file info_ is provided. This includes modigied time, hasInfoFile, and imageLocal
among others.
"""
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
files = folder_paths.get_filename_list(model_type)
format_param = get_param(request, 'format')
if format_param == 'details':
response = []
bad_files_first = None
bad_files_num = 0
for file in files:
file_info = get_file_info(file, model_type)
# Some folks were seeing null in this list, which is odd since it's coming from ComfyUI files.
# See https://github.com/rgthree/rgthree-comfy/issues/574#issuecomment-3494629132 We'll check
# and log if we haven't found, maybe someone will have more info.
if file_info is not None:
response.append(file_info)
else:
bad_files_num += 1
if not bad_files_first:
bad_files_first = file
if bad_files_first:
log(
f"Couldn't get file info for {bad_files_first}"
f"{f' and {bad_files_num} other {model_type}.' if bad_files_num > 1 else '.'} "
"ComfyUI thinks they exist, but they were not found on the filesystem.",
prefix="Power Lora Loader",
color="YELLOW",
id=f'no_file_details_{model_type}',
at_most_secs=30
)
return web.json_response(response)
return web.json_response(list(files))
@routes.get('/rgthree/api/{type}/info')
async def api_get_models_info(request):
"""Returns a list model info; either all or a specific ones if provided a 'files' param.
If a `light` param is specified and not falsy, no metadata will be fetched.
"""
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
files_param = get_param(request, 'files')
maybe_fetch_metadata = files_param is not None
if not is_param_falsy(request, 'light'):
maybe_fetch_metadata = False
api_response = await models_info_response(
request, model_type, maybe_fetch_metadata=maybe_fetch_metadata
)
return web.json_response(api_response)
@routes.get('/rgthree/api/{type}/info/refresh')
async def api_get_refresh_get_models_info(request):
"""Refreshes model info; either all or specific ones if provided a 'files' param. """
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
api_response = await models_info_response(
request, model_type, maybe_fetch_civitai=True, maybe_fetch_metadata=True
)
return web.json_response(api_response)
@routes.get('/rgthree/api/{type}/info/clear')
async def api_get_delete_model_info(request):
"""Clears model info from the filesystem for the provided file."""
if _check_valid_model_type(request):
return _check_valid_model_type(request)
api_response = {'status': 200}
model_type = request.match_info['type']
files_param = get_param(request, 'files')
if files_param is not None:
files_param = files_param.split(',')
del_info = not is_param_falsy(request, 'del_info')
del_metadata = not is_param_falsy(request, 'del_metadata')
del_civitai = not is_param_falsy(request, 'del_civitai')
if not files_param:
api_response['status'] = '404'
api_response['error'] = f'No file provided. Please pass files=ALL to clear {model_type} info.'
else:
if len(files_param) == 1 and files_param[
0] == "ALL": # Force the user to supply files=ALL to trigger all clearing.
files_param = folder_paths.get_filename_list(model_type)
for file_param in files_param:
await delete_model_info(
file_param,
model_type,
del_info=del_info,
del_metadata=del_metadata,
del_civitai=del_civitai
)
return web.json_response(api_response)
@routes.post('/rgthree/api/{type}/info')
async def api_post_save_model_data(request):
"""Saves data to a model by name. """
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
api_response = {'status': 200}
file_param = get_param(request, 'file')
if file_param is None:
api_response['status'] = '404'
api_response['error'] = 'No model found at path'
else:
post = await request.post()
await set_model_info_partial(file_param, model_type, json.loads(post.get("json")))
info_data = await get_model_info(file_param, model_type)
api_response['data'] = info_data
return web.json_response(api_response)
@routes.get('/rgthree/api/{type}/img')
async def api_get_models_info_img(request):
""" Returns an image response if one exists for the model. """
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
file_param = get_param(request, 'file')
file_path = folder_paths.get_full_path(model_type, file_param)
if not path_exists(file_path):
file_path = abspath(file_path)
img_path = None
for ext in ['jpg', 'png', 'jpeg']:
try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
if path_exists(try_path):
img_path = try_path
break
if not path_exists(img_path):
api_response = {}
api_response['status'] = '404'
api_response['error'] = 'No model found at path'
return web.json_response(api_response)
return web.FileResponse(img_path)
async def models_info_response(
request, model_type, maybe_fetch_civitai=False, maybe_fetch_metadata=False
):
"""Gets model info for all or a single model type."""
api_response = {'status': 200, 'data': []}
light = not is_param_falsy(request, 'light')
files_param = get_param(request, 'files')
if files_param is not None:
files_param = files_param.split(',')
else:
files_param = folder_paths.get_filename_list(model_type)
for file_param in files_param:
info_data = await get_model_info(
file_param,
model_type,
maybe_fetch_civitai=maybe_fetch_civitai,
maybe_fetch_metadata=maybe_fetch_metadata,
light=light
)
api_response['data'].append(info_data)
return api_response
+452
View File
@@ -0,0 +1,452 @@
import hashlib
import json
import os
import re
from datetime import datetime
import requests
from server import PromptServer
import folder_paths
from ..utils import abspath, get_dict_value, load_json_file, file_exists, remove_path, save_json_file
from ..utils_userdata import read_userdata_json, save_userdata_json, delete_userdata_file
def _get_info_cache_file(data_type: str, file_hash: str):
return f'info/{file_hash}.{data_type}.json'
async def delete_model_info(
file: str, model_type, del_info=True, del_metadata=True, del_civitai=True
):
"""Delete the info json, and the civitai & metadata caches."""
file_path = get_folder_path(file, model_type)
if file_path is None:
return
if del_info:
remove_path(get_info_file(file_path))
if del_civitai or del_metadata:
file_hash = _get_sha256_hash(file_path)
if del_civitai:
json_file_path = _get_info_cache_file(file_hash, 'civitai')
delete_userdata_file(json_file_path)
if del_metadata:
json_file_path = _get_info_cache_file(file_hash, 'metadata')
delete_userdata_file(json_file_path)
def get_file_info(file: str, model_type):
"""Gets basic file info, like created or modified date."""
file_path = get_folder_path(file, model_type)
if file_path is None:
return None
return {
'file': file,
'path': file_path,
'modified': os.path.getmtime(file_path) * 1000, # millis
'imageLocal': f'/rgthree/api/{model_type}/img?file={file}' if get_img_file(file_path) else None,
'hasInfoFile': get_info_file(file_path) is not None,
}
def get_info_file(file_path: str, force=False):
# Try to load a rgthree-info.json file next to the file.
info_path = f'{file_path}.rgthree-info.json'
return info_path if file_exists(info_path) or force else None
def get_img_file(file_path: str, force=False):
for ext in ['jpg', 'png', 'jpeg', 'webp']:
try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
if file_exists(try_path):
return try_path
def get_model_info_file_data(file: str, model_type, default=None):
"""Returns the data from the info file, or a default value if it doesn't exist."""
file_path = get_folder_path(file, model_type)
if file_path is None:
return default
return load_json_file(get_info_file(file_path), default=default)
async def get_model_info(
file: str,
model_type,
default=None,
maybe_fetch_civitai=False,
force_fetch_civitai=False,
maybe_fetch_metadata=False,
force_fetch_metadata=False,
light=False
):
"""Compiles a model info given a stored file next to the model, and/or metadata/civitai."""
file_path = get_folder_path(file, model_type)
if file_path is None:
return default
should_save = False
# basic data
basic_data = get_file_info(file, model_type)
# Try to load a rgthree-info.json file next to the file.
info_data = get_model_info_file_data(file, model_type, default={})
for key in ['file', 'path', 'modified', 'imageLocal', 'hasInfoFile']:
if key in basic_data and basic_data[key] and (
key not in info_data or info_data[key] != basic_data[key]
):
info_data[key] = basic_data[key]
should_save = True
# Check if we have an image next to the file and, if so, add it to the front of the images
# (if it isn't already).
img_next_to_file = basic_data['imageLocal']
if 'images' not in info_data:
info_data['images'] = []
should_save = True
if img_next_to_file:
if len(info_data['images']) == 0 or info_data['images'][0]['url'] != img_next_to_file:
info_data['images'].insert(0, {'url': img_next_to_file})
should_save = True
# If we just want light data then bail now with just existing data, plus file, path and img if
# next to the file.
if light and not maybe_fetch_metadata and not force_fetch_metadata and not maybe_fetch_civitai and not force_fetch_civitai:
return info_data
if 'raw' not in info_data:
info_data['raw'] = {}
should_save = True
should_save = _update_data(info_data) or should_save
should_fetch_civitai = force_fetch_civitai is True or (
maybe_fetch_civitai is True and 'civitai' not in info_data['raw']
)
should_fetch_metadata = force_fetch_metadata is True or (
maybe_fetch_metadata is True and 'metadata' not in info_data['raw']
)
if should_fetch_metadata:
data_meta = _get_model_metadata(file, model_type, default={}, refresh=force_fetch_metadata)
should_save = _merge_metadata(info_data, data_meta) or should_save
if should_fetch_civitai:
data_civitai = _get_model_civitai_data(
file, model_type, default={}, refresh=force_fetch_civitai
)
should_save = _merge_civitai_data(info_data, data_civitai) or should_save
if 'sha256' not in info_data:
file_hash = _get_sha256_hash(file_path)
if file_hash is not None:
info_data['sha256'] = file_hash
should_save = True
if should_save:
if 'trainedWords' in info_data:
# Sort by count; if it doesn't exist, then assume it's a top item from civitai or elsewhere.
info_data['trainedWords'] = sorted(
info_data['trainedWords'],
key=lambda w: w['count'] if 'count' in w else 99999,
reverse=True
)
save_model_info(file, info_data, model_type)
# If we're saving, then the UI is likely waiting to see if the refreshed data is coming in.
await PromptServer.instance.send(f"rgthree-refreshed-{model_type}-info", {"data": info_data})
return info_data
def _update_data(info_data: dict) -> bool:
"""Ports old data to new data if necessary."""
should_save = False
# If we have "triggerWords" then move them over to "trainedWords"
if 'triggerWords' in info_data and len(info_data['triggerWords']) > 0:
civitai_words = ','.join((
get_dict_value(info_data, 'raw.civitai.triggerWords', default=[]) +
get_dict_value(info_data, 'raw.civitai.trainedWords', default=[])
))
if 'trainedWords' not in info_data:
info_data['trainedWords'] = []
for trigger_word in info_data['triggerWords']:
word_data = next((data for data in info_data['trainedWords'] if data['word'] == trigger_word),
None)
if word_data is None:
word_data = {'word': trigger_word}
info_data['trainedWords'].append(word_data)
if trigger_word in civitai_words:
word_data['civitai'] = True
else:
word_data['user'] = True
del info_data['triggerWords']
should_save = True
return should_save
def _merge_metadata(info_data: dict, data_meta: dict) -> bool:
"""Returns true if data was saved."""
should_save = False
base_model_file = get_dict_value(data_meta, 'ss_sd_model_name', None)
if base_model_file:
info_data['baseModelFile'] = base_model_file
# Loop over metadata tags
trained_words = {}
if 'ss_tag_frequency' in data_meta and isinstance(data_meta['ss_tag_frequency'], dict):
for bucket_value in data_meta['ss_tag_frequency'].values():
if isinstance(bucket_value, dict):
for tag, count in bucket_value.items():
if tag not in trained_words:
trained_words[tag] = {'word': tag, 'count': 0, 'metadata': True}
trained_words[tag]['count'] = trained_words[tag]['count'] + count
if 'trainedWords' not in info_data:
info_data['trainedWords'] = list(trained_words.values())
should_save = True
else:
# We can't merge, because the list may have other data, like it's part of civitaidata.
merged_dict = {}
for existing_word_data in info_data['trainedWords']:
merged_dict[existing_word_data['word']] = existing_word_data
for new_key, new_word_data in trained_words.items():
if new_key not in merged_dict:
merged_dict[new_key] = {}
merged_dict[new_key] = {**merged_dict[new_key], **new_word_data}
info_data['trainedWords'] = list(merged_dict.values())
should_save = True
# trained_words = list(trained_words.values())
# info_data['meta_trained_words'] = trained_words
info_data['raw']['metadata'] = data_meta
should_save = True
if 'sha256' not in info_data and '_sha256' in data_meta:
info_data['sha256'] = data_meta['_sha256']
should_save = True
return should_save
def _merge_civitai_data(info_data: dict, data_civitai: dict) -> bool:
"""Returns true if data was saved."""
should_save = False
if 'name' not in info_data:
info_data['name'] = get_dict_value(data_civitai, 'model.name', '')
should_save = True
version_name = get_dict_value(data_civitai, 'name')
if version_name is not None:
info_data['name'] += f' - {version_name}'
if 'type' not in info_data:
info_data['type'] = get_dict_value(data_civitai, 'model.type')
should_save = True
if 'baseModel' not in info_data:
info_data['baseModel'] = get_dict_value(data_civitai, 'baseModel')
should_save = True
# We always want to merge triggerword.
civitai_trigger = get_dict_value(data_civitai, 'triggerWords', default=[])
civitai_trained = get_dict_value(data_civitai, 'trainedWords', default=[])
civitai_words = ','.join(civitai_trigger + civitai_trained)
if civitai_words:
civitai_words = re.sub(r"\s*,\s*", ",", civitai_words)
civitai_words = re.sub(r",+", ",", civitai_words)
civitai_words = re.sub(r"^,", "", civitai_words)
civitai_words = re.sub(r",$", "", civitai_words)
if civitai_words:
civitai_words = civitai_words.split(',')
if 'trainedWords' not in info_data:
info_data['trainedWords'] = []
for trigger_word in civitai_words:
word_data = next(
(data for data in info_data['trainedWords'] if data['word'] == trigger_word), None
)
if word_data is None:
word_data = {'word': trigger_word}
info_data['trainedWords'].append(word_data)
word_data['civitai'] = True
if 'sha256' not in info_data:
info_data['sha256'] = data_civitai['_sha256']
should_save = True
if 'modelId' in data_civitai:
info_data['links'] = info_data['links'] if 'links' in info_data else []
civitai_link = f'https://civitai.com/models/{get_dict_value(data_civitai, "modelId")}'
if get_dict_value(data_civitai, "id"):
civitai_link += f'?modelVersionId={get_dict_value(data_civitai, "id")}'
info_data['links'].append(civitai_link)
info_data['links'].append(data_civitai['_civitai_api'])
should_save = True
# Take images from civitai
if 'images' in data_civitai:
info_data_image_urls = list(
map(lambda i: i['url'] if 'url' in i else None, info_data['images'])
)
for img in data_civitai['images']:
img_url = get_dict_value(img, 'url')
if img_url is not None and img_url not in info_data_image_urls:
img_id = os.path.splitext(os.path.basename(img_url))[0] if img_url is not None else None
img_data = {
'url': img_url,
'civitaiUrl': f'https://civitai.com/images/{img_id}' if img_id is not None else None,
'width': get_dict_value(img, 'width'),
'height': get_dict_value(img, 'height'),
'type': get_dict_value(img, 'type'),
'nsfwLevel': get_dict_value(img, 'nsfwLevel'),
'seed': get_dict_value(img, 'meta.seed'),
'positive': get_dict_value(img, 'meta.prompt'),
'negative': get_dict_value(img, 'meta.negativePrompt'),
'steps': get_dict_value(img, 'meta.steps'),
'sampler': get_dict_value(img, 'meta.sampler'),
'cfg': get_dict_value(img, 'meta.cfgScale'),
'model': get_dict_value(img, 'meta.Model'),
'resources': get_dict_value(img, 'meta.resources'),
}
info_data['images'].append(img_data)
should_save = True
# The raw data
if 'civitai' not in info_data['raw']:
info_data['raw']['civitai'] = data_civitai
should_save = True
return should_save
def _get_model_civitai_data(file: str, model_type, default=None, refresh=False):
"""Gets the civitai data, either cached from the user directory, or from civitai api."""
file_hash = _get_sha256_hash(get_folder_path(file, model_type))
if file_hash is None:
return None
json_file_path = _get_info_cache_file(file_hash, 'civitai')
api_url = f'https://civitai.com/api/v1/model-versions/by-hash/{file_hash}'
file_data = read_userdata_json(json_file_path)
if file_data is None or refresh is True:
try:
response = requests.get(api_url, timeout=5000)
data = response.json()
save_userdata_json(
json_file_path, {
'url': api_url,
'timestamp': datetime.now().timestamp(),
'response': data
}
)
file_data = read_userdata_json(json_file_path)
except requests.exceptions.RequestException as e: # This is the correct syntax
print(e)
response = file_data['response'] if file_data is not None and 'response' in file_data else None
if response is not None:
response['_sha256'] = file_hash
response['_civitai_api'] = api_url
return response if response is not None else default
def _get_model_metadata(file: str, model_type, default=None, refresh=False):
"""Gets the metadata from the file itself."""
file_path = get_folder_path(file, model_type)
file_hash = _get_sha256_hash(file_path)
if file_hash is None:
return default
json_file_path = _get_info_cache_file(file_hash, 'metadata')
file_data = read_userdata_json(json_file_path)
if file_data is None or refresh is True:
data = _read_file_metadata_from_header(file_path)
if data is not None:
file_data = {'url': file, 'timestamp': datetime.now().timestamp(), 'response': data}
save_userdata_json(json_file_path, file_data)
response = file_data['response'] if file_data is not None and 'response' in file_data else None
if response is not None:
response['_sha256'] = file_hash
return response if response is not None else default
def _read_file_metadata_from_header(file_path: str) -> dict:
"""Reads the file's header and returns a JSON dict metdata if available."""
data = None
try:
if file_path.endswith('.safetensors'):
with open(file_path, "rb") as file:
# https://github.com/huggingface/safetensors#format
# 8 bytes: N, an unsigned little-endian 64-bit integer, containing the size of the header
header_size = int.from_bytes(file.read(8), "little", signed=False)
if header_size <= 0:
raise BufferError("Invalid header size")
header = file.read(header_size)
if header is None:
raise BufferError("Invalid header")
header_json = json.loads(header)
data = header_json["__metadata__"] if "__metadata__" in header_json else None
if data is not None:
for key, value in data.items():
if isinstance(value, str) and value.startswith('{') and value.endswith('}'):
try:
value_as_json = json.loads(value)
data[key] = value_as_json
except Exception:
print(f'metdata for field {key} did not parse as json')
except requests.exceptions.RequestException as e:
print(e)
data = None
return data
def get_folder_path(file: str, model_type) -> str | None:
"""Gets the file path ensuring it exists."""
file_path = folder_paths.get_full_path(model_type, file)
if not file_exists(file_path):
file_path = abspath(file_path)
if not file_exists(file_path):
file_path = None
return file_path
def _get_sha256_hash(file_path: str | None):
"""Returns the hash for the file."""
if not file_path or not file_exists(file_path):
return None
BUF_SIZE = 1024 * 128 # lets read stuff in 64kb chunks!
file_hash = None
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
# Read and update hash string value in blocks of BUF_SIZE
for byte_block in iter(lambda: f.read(BUF_SIZE), b""):
sha256_hash.update(byte_block)
file_hash = sha256_hash.hexdigest()
return file_hash
async def set_model_info_partial(file: str, model_type: str, info_data_partial):
"""Sets partial data into the existing model info data."""
info_data = await get_model_info(file, model_type, default={})
info_data = {**info_data, **info_data_partial}
save_model_info(file, info_data, model_type)
def save_model_info(file: str, info_data, model_type):
"""Saves the model info alongside the model itself."""
file_path = get_folder_path(file, model_type)
if file_path is None:
return
info_path = get_info_file(file_path, force=True)
save_json_file(info_path, info_data)
+65
View File
@@ -0,0 +1,65 @@
import os
from aiohttp import web
from ..utils import sub_abspath
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
DIR_WEB = os.path.abspath(f'{THIS_DIR}/../../web/')
def get_param(request, param, default=None):
"""Gets a param from a request."""
return request.rel_url.query[param] if param in request.rel_url.query else default
def is_param_falsy(request, param):
"""Determines if a param is explicitly 0 or false."""
val = get_param(request, param)
return val is not None and (val == "0" or val.upper() == "FALSE")
def is_param_truthy(request, param):
"""Determines if a param is explicitly 0 or false."""
val = get_param(request, param)
return val is not None and not is_param_falsy(request, param)
def set_default_page_resources(path, routes):
"""Sets up routes for handling static files under a path."""
@routes.get(f'/rgthree/{path}/{{file}}')
async def get_resource(request):
"""Returns a resource file."""
filepath = request.match_info['file']
abspath = sub_abspath(os.path.join(DIR_WEB, path), filepath)
if abspath is None:
return web.HTTPNotFound()
return web.FileResponse(abspath)
@routes.get(f'/rgthree/{path}/{{subdir}}/{{file}}')
async def get_resource_subdir(request):
"""Returns a resource file."""
filepath = os.path.join(request.match_info['subdir'], request.match_info['file'])
abspath = sub_abspath(os.path.join(DIR_WEB, path), filepath)
if abspath is None:
return web.HTTPNotFound()
return web.FileResponse(abspath)
def set_default_page_routes(path, routes):
""" Sets default path handling for a hosted rgthree page. """
@routes.get(f'/rgthree/{path}')
async def get_path_redir(request):
""" Redirects to the path adding a trailing slash. """
raise web.HTTPFound(f'{request.path}/')
@routes.get(f'/rgthree/{path}/')
async def get_path_index(request):
""" Handles the page's index loading. """
html = ''
with open(os.path.join(DIR_WEB, path, 'index.html'), 'r', encoding='UTF-8') as file:
html = file.read()
return web.Response(text=html, content_type='text/html')
set_default_page_resources(path, routes)
+177
View File
@@ -0,0 +1,177 @@
import json
import os
import re
from typing import Union
class AnyType(str):
"""A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
def __ne__(self, __value: object) -> bool:
return False
class FlexibleOptionalInputType(dict):
"""A special class to make flexible nodes that pass data to our python handlers.
Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
(like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc).
Initially, ComfyUI only needed to return True for `__contains__` below, which told ComfyUI that
our node will handle the input, regardless of what it is.
However, after https://github.com/comfyanonymous/ComfyUI/pull/2666 ComdyUI's execution changed
also checking the data for the key; specifcially, the type which is the first tuple entry. This
type is supplied to our FlexibleOptionalInputType and returned for any non-data key. This can be a
real type, or use the AnyType for additional flexibility.
"""
def __init__(self, type, data: Union[dict, None] = None):
"""Initializes the FlexibleOptionalInputType.
Args:
type: The flexible type to use when ComfyUI retrieves an unknown key (via `__getitem__`).
data: An optional dict to use as the basis. This is stored both in a `data` attribute, so we
can look it up without hitting our overrides, as well as iterated over and adding its key
and values to our `self` keys. This way, when looked at, we will appear to represent this
data. When used in an "optional" INPUT_TYPES, these are the starting optional node types.
"""
self.type = type
self.data = data
if self.data is not None:
for k, v in self.data.items():
self[k] = v
def __getitem__(self, key):
# If we have this key in the initial data, then return it. Otherwise return the tuple with our
# flexible type.
if self.data is not None and key in self.data:
val = self.data[key]
return val
return (self.type,)
def __contains__(self, key):
"""Always contain a key, and we'll always return the tuple above when asked for it."""
return True
any_type = AnyType("*")
def is_dict_value_falsy(data: dict, dict_key: str):
"""Checks if a dict value is falsy."""
val = get_dict_value(data, dict_key)
return not val
def get_dict_value(data: dict, dict_key: str, default=None):
"""Gets a deeply nested value given a dot-delimited key."""
keys = dict_key.split('.')
key = keys.pop(0) if len(keys) > 0 else None
found = data[key] if key in data else None
if found is not None and len(keys) > 0:
return get_dict_value(found, '.'.join(keys), default)
return found if found is not None else default
def set_dict_value(data: dict, dict_key: str, value, create_missing_objects=True):
"""Sets a deeply nested value given a dot-delimited key."""
keys = dict_key.split('.')
key = keys.pop(0) if len(keys) > 0 else None
if key not in data:
if create_missing_objects is False:
return data
data[key] = {}
if len(keys) == 0:
data[key] = value
else:
set_dict_value(data[key], '.'.join(keys), value, create_missing_objects)
return data
def dict_has_key(data: dict, dict_key):
"""Checks if a dict has a deeply nested dot-delimited key."""
keys = dict_key.split('.')
key = keys.pop(0) if len(keys) > 0 else None
if key is None or key not in data:
return False
if len(keys) == 0:
return True
return dict_has_key(data[key], '.'.join(keys))
def load_json_file(file: str, default=None):
"""Reads a json file and returns the json dict, stripping out "//" comments first."""
if path_exists(file):
with open(file, 'r', encoding='UTF-8') as file:
config = file.read()
try:
return json.loads(config)
except json.decoder.JSONDecodeError:
try:
config = re.sub(r"^\s*//\s.*", "", config, flags=re.MULTILINE)
return json.loads(config)
except json.decoder.JSONDecodeError:
try:
config = re.sub(r"(?:^|\s)//.*", "", config, flags=re.MULTILINE)
return json.loads(config)
except json.decoder.JSONDecodeError:
pass
return default
def save_json_file(file_path: str, data: dict):
"""Saves a json file."""
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w+', encoding='UTF-8') as file:
json.dump(data, file, sort_keys=False, indent=2, separators=(",", ": "))
def path_exists(path):
"""Checks if a path exists, accepting None type."""
if path is not None:
return os.path.exists(path)
return False
def file_exists(path):
"""Checks if a file exists, accepting None type."""
if path is not None:
return os.path.isfile(path)
return False
def remove_path(path):
"""Removes a path, if it exists."""
if path_exists(path):
os.remove(path)
return True
return False
def abspath(file_path: str):
"""Resolves the abspath of a file, resolving symlinks and user dirs."""
abs_path = os.path.abspath(file_path) if file_path else file_path
if abs_path and not path_exists(abs_path):
maybe_path = os.path.abspath(os.path.realpath(os.path.expanduser(file_path)))
abs_path = maybe_path if path_exists(maybe_path) else abs_path
return abs_path
def sub_abspath(parent_dir: str, rel_path: str):
"""Resolves the abspath under a parent directory ensuring it exists and is contained within."""
rel_path = os.path.join(parent_dir, rel_path)
abs_path = abspath(rel_path)
if not path_exists(abs_path) or not abs_path.startswith(parent_dir):
return None
return abs_path
class ByPassTypeTuple(tuple):
"""A special class that will return additional "AnyType" strings beyond defined values.
Credit to Trung0246
"""
def __getitem__(self, index):
if index > len(self) - 1:
return AnyType("*")
return super().__getitem__(index)
+20
View File
@@ -0,0 +1,20 @@
from .utils import get_dict_value
def get_worflow_node(extra_pnginfo, node_id: str, default=None):
# First, break out of any subgraphs
node_ids = str(node_id).split(':')
workflow_nodes = get_dict_value(extra_pnginfo, 'workflow.nodes', default=[])
workflow_subgraphs = get_dict_value(extra_pnginfo, 'workflow.definitions.subgraphs', default=[])
nodes_list = workflow_nodes
found = None
for individual_node_id in node_ids:
found = next((n for n in nodes_list if str(n['id']) == individual_node_id), None)
if isinstance(found, dict) and 'type' in found:
# Are we a subgraph? Right now, subgraph types are a UUID that exists as an id in the
# aubgraphs list. But, rather than check if we're a UUID, let's just check if it exists
# anyway, that when if (when) Comfy changes the id structure we'll keep working.
subgraph = next((n for n in workflow_subgraphs if str(n['id']) == found['type']), None)
if isinstance(subgraph, dict) and 'nodes' in subgraph:
nodes_list = subgraph['nodes']
return found if found is not None else default
+50
View File
@@ -0,0 +1,50 @@
import os
from .utils import load_json_file, path_exists, save_json_file
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
USERDATA = os.path.join(THIS_DIR, '..', 'userdata')
def read_userdata_file(rel_path: str, default=None):
"""Reads a file from the userdata directory."""
file_path = clean_path(rel_path)
if path_exists(file_path):
with open(file_path, 'r', encoding='UTF-8') as file:
return file.read()
return default
def save_userdata_file(rel_path: str, content: str):
"""Saves a file from the userdata directory."""
file_path = clean_path(rel_path)
with open(file_path, 'w+', encoding='UTF-8') as file:
file.write(content)
def delete_userdata_file(rel_path: str):
"""Deletes a file from the userdata directory."""
file_path = clean_path(rel_path)
if os.path.isfile(file_path):
os.remove(file_path)
def read_userdata_json(rel_path: str, default=None):
"""Reads a json file from the userdata directory."""
file_path = clean_path(rel_path)
return load_json_file(file_path, default)
def save_userdata_json(rel_path: str, data: dict):
"""Saves a json file from the userdata directory."""
file_path = clean_path(rel_path)
return save_json_file(file_path, data)
def clean_path(rel_path: str):
"""Cleans a relative path by splitting on forward slash and os.path.joining."""
cleaned = USERDATA
paths = rel_path.split('/')
for path in paths:
cleaned = os.path.join(cleaned, path)
return cleaned
+14
View File
@@ -0,0 +1,14 @@
[project]
name = "rgthree-comfy"
description = "Making ComfyUI more comfortable."
version = "1.0.2610050029"
license = { file = "LICENSE" }
dependencies = []
[project.urls]
Repository = "https://github.com/rgthree/rgthree-comfy"
[tool.comfy]
PublisherId = "rgthree"
DisplayName = "rgthree-comfy"
Icon = "https://comfy.rgthree.com/media/rgthree.svg"
+69
View File
@@ -0,0 +1,69 @@
// COPY THIS FILE BEFORE MAKING CHANGES TO: rgthree_config.json
{
"log_level": "WARN",
"features": {
"show_alerts_for_corrupt_workflows": false,
"monitor_for_corrupt_links": false,
"menu_queue_selected_nodes": true,
"menu_auto_nest": {
"subdirs": null,
"threshold": 20
},
"menu_bookmarks": {
"enabled": true
},
"group_header_fast_toggle": {
"enabled": null,
"toggles": ["queue", "bypass", "mute"],
"show": "hover"
},
"progress_bar": {
"enabled": true,
"height": 16,
"position": "top"
},
"comfy_top_bar_menu": {
"enabled": true,
"button_bookmarks": {
"enabled": true
}
},
// Allows for dragging and dropping a workflow (image, json) onto an individual node to import
// that specific node's widgets if it also exists in the dropped workflow (same id, type).
"import_individual_nodes": {
"enabled": null
},
// Enables invokeExtensionsAsync for rgthree-nodes allowing other extensions to hook into the
// nodes like the default ComfyNodes. This was not possible before Apr 2024, so it's a config
// entry in case it causes issues. This is only for the nodeCreated event/function as of now.
"invoke_extensions_async": {
"node_created": true
}
},
"nodes": {
"reroute": {
"default_width": 40,
"default_height": 30,
"default_resizable": false,
"default_layout": ["Left", "Right"],
"fast_reroute": {
"enabled": true,
"key_create_while_dragging_link" : "Shift + R",
"key_rotate": "Shift + A",
"key_resize": "Shift + X",
"key_move": "Shift + Z",
"key_connections_input": "Shift + S",
"key_connections_output": "Shift + D"
}
},
"power_lora_loader": {
"show_info_badge": true,
"info_autoplay_video": true
}
},
"announcements": {
"comfy-nodes-20": {
"incompatible": true
}
}
}
+103
View File
@@ -0,0 +1,103 @@
import type {
ComfyApp,
ComfyNodeDef,
INodeInputSlot,
INodeOutputSlot,
LGraphNode,
LLink,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {IoDirection, addConnectionLayoutSupport, followConnectionUntilType} from "./utils.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
import {debounce} from "rgthree/common/shared_utils.js";
class RgthreeAnySwitch extends RgthreeBaseServerNode {
static override title = NodeTypesString.ANY_SWITCH;
static override type = NodeTypesString.ANY_SWITCH;
static comfyClass = NodeTypesString.ANY_SWITCH;
private stabilizeBound = this.stabilize.bind(this);
private nodeType: string | string[] | null = null;
constructor(title = RgthreeAnySwitch.title) {
super(title);
// Adding five. Note, configure will add as many as was in the stored workflow automatically.
this.addAnyInput(5);
}
override onConnectionsChange(
type: number,
slotIndex: number,
isConnected: boolean,
linkInfo: LLink,
ioSlot: INodeOutputSlot | INodeInputSlot,
) {
super.onConnectionsChange?.(type, slotIndex, isConnected, linkInfo, ioSlot);
this.scheduleStabilize();
}
onConnectionsChainChange() {
this.scheduleStabilize();
}
scheduleStabilize(ms = 64) {
return debounce(this.stabilizeBound, ms);
}
private addAnyInput(num = 1) {
for (let i = 0; i < num; i++) {
this.addInput(
`any_${String(this.inputs.length + 1).padStart(2, "0")}`,
(this.nodeType || "*") as string,
);
}
}
stabilize() {
// First, clean up the dynamic number of inputs.
removeUnusedInputsFromEnd(this, 4);
this.addAnyInput();
// We prefer the inputs, then the output.
let connectedType = followConnectionUntilType(this, IoDirection.INPUT, undefined, true);
if (!connectedType) {
connectedType = followConnectionUntilType(this, IoDirection.OUTPUT, undefined, true);
}
// TODO: What this doesn't do is broadcast to other nodes when its type changes. Reroute node
// does, but, for now, if this was connected to another Any Switch, say, the second one wouldn't
// change its type when the first does. The user would need to change the connections.
this.nodeType = connectedType?.type || "*";
for (const input of this.inputs) {
input.type = this.nodeType as string; // So, types can indeed be arrays,,
}
for (const output of this.outputs) {
output.type = this.nodeType as string; // So, types can indeed be arrays,,
output.label =
output.type === "RGTHREE_CONTEXT"
? "CONTEXT"
: Array.isArray(this.nodeType) || this.nodeType.includes(",")
? connectedType?.label || String(this.nodeType)
: String(this.nodeType);
}
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeAnySwitch);
addConnectionLayoutSupport(RgthreeAnySwitch, app, [
["Left", "Right"],
["Right", "Left"],
]);
}
}
app.registerExtension({
name: "rgthree.AnySwitch",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: any, app: ComfyApp) {
if (nodeData.name === "Any Switch (rgthree)") {
RgthreeAnySwitch.setUp(nodeType, nodeData);
}
},
});
@@ -0,0 +1,354 @@
import type {
Vector2,
LLink,
INodeInputSlot,
INodeOutputSlot,
LGraphNode as TLGraphNode,
ISlotType,
ConnectByTypeOptions,
TWidgetType,
IWidgetOptions,
IWidget,
IBaseWidget,
WidgetTypeMap,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {rgthree} from "./rgthree.js";
import {
PassThroughFollowing,
addConnectionLayoutSupport,
addMenuItem,
getConnectedInputNodes,
getConnectedInputNodesAndFilterPassThroughs,
getConnectedOutputNodes,
getConnectedOutputNodesAndFilterPassThroughs,
} from "./utils.js";
/**
* A Virtual Node that allows any node's output to connect to it.
*/
export class BaseAnyInputConnectedNode extends RgthreeBaseVirtualNode {
override isVirtualNode = true;
/**
* Whether inputs show the immediate nodes, or follow and show connected nodes through
* passthrough nodes.
*/
readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.NONE;
debouncerTempWidth: number = 0;
schedulePromise: Promise<void> | null = null;
constructor(title = BaseAnyInputConnectedNode.title) {
super(title);
}
override onConstructed() {
this.addInput("", "*");
return super.onConstructed();
}
override clone() {
const cloned = super.clone()!;
// Copying to clipboard (and also, creating node templates) work by cloning nodes and, for some
// reason, it manually manipulates the cloned data. So, we want to keep the present input slots
// so if it's pasted/templatized the data is correct. Otherwise, clear the inputs and so the new
// node is ready to go, fresh.
if (!rgthree.canvasCurrentlyCopyingToClipboardWithMultipleNodes) {
while (cloned.inputs.length > 1) {
cloned.removeInput(cloned.inputs.length - 1);
}
if (cloned.inputs[0]) {
cloned.inputs[0].label = "";
}
}
return cloned;
}
/**
* Schedules a promise to run a stabilization, debouncing duplicate requests.
*/
scheduleStabilizeWidgets(ms = 100) {
if (!this.schedulePromise) {
this.schedulePromise = new Promise((resolve) => {
setTimeout(() => {
this.schedulePromise = null;
this.doStablization();
resolve();
}, ms);
});
}
return this.schedulePromise;
}
/**
* Ensures we have at least one empty input at the end, returns true if changes were made, or false
* if no changes were needed.
*/
private stabilizeInputsOutputs(): boolean {
let changed = false;
const hasEmptyInput = !this.inputs[this.inputs.length - 1]?.link;
if (!hasEmptyInput) {
this.addInput("", "*");
changed = true;
}
for (let index = this.inputs.length - 2; index >= 0; index--) {
const input = this.inputs[index]!;
if (!input.link) {
this.removeInput(index);
changed = true;
} else {
const node = getConnectedInputNodesAndFilterPassThroughs(
this,
this,
index,
this.inputsPassThroughFollowing,
)[0];
const newName = node?.title || "";
if (input.name !== newName) {
input.name = node?.title || "";
changed = true;
}
}
}
return changed;
}
/**
* Stabilizes the node's inputs and widgets.
*/
private doStablization() {
if (!this.graph) {
return;
}
let dirty = false;
// When we add/remove widgets, litegraph is going to mess up the size, so we
// store it so we can retrieve it in computeSize. Hacky..
(this as any)._tempWidth = this.size[0];
dirty = this.stabilizeInputsOutputs();
const linkedNodes = getConnectedInputNodesAndFilterPassThroughs(this);
dirty = this.handleLinkedNodesStabilization(linkedNodes) || dirty;
// Only mark dirty if something's changed.
if (dirty) {
this.graph.setDirtyCanvas(true, true);
}
// Schedule another stabilization in the future.
this.scheduleStabilizeWidgets(500);
}
/**
* Handles stabilization of linked nodes. To be overridden. Should return true if changes were
* made, or false if no changes were needed.
*/
handleLinkedNodesStabilization(linkedNodes: TLGraphNode[]): boolean {
linkedNodes; // No-op, but makes overridding in VSCode cleaner.
throw new Error("handleLinkedNodesStabilization should be overridden.");
}
onConnectionsChainChange() {
this.scheduleStabilizeWidgets();
}
override onConnectionsChange(
type: number,
index: number,
connected: boolean,
linkInfo: LLink,
ioSlot: INodeOutputSlot | INodeInputSlot,
) {
super.onConnectionsChange &&
super.onConnectionsChange(type, index, connected, linkInfo, ioSlot);
if (!linkInfo) return;
// Follow outputs to see if we need to trigger an onConnectionChange.
const connectedNodes = getConnectedOutputNodesAndFilterPassThroughs(this);
for (const node of connectedNodes) {
if ((node as BaseAnyInputConnectedNode).onConnectionsChainChange) {
(node as BaseAnyInputConnectedNode).onConnectionsChainChange();
}
}
this.scheduleStabilizeWidgets();
}
override removeInput(slot: number) {
(this as any)._tempWidth = this.size[0];
return super.removeInput(slot);
}
override addInput<TProperties extends Partial<INodeInputSlot>>(
name: string,
type: ISlotType,
extra_info?: TProperties | undefined,
): INodeInputSlot & TProperties {
(this as any)._tempWidth = this.size[0];
return super.addInput(name, type, extra_info);
}
override addWidget<Type extends TWidgetType, TValue extends WidgetTypeMap[Type]["value"]>(
type: Type,
name: string,
value: TValue,
callback: IBaseWidget["callback"] | string | null,
options?: IWidgetOptions | string,
):
| IBaseWidget<string | number | boolean | object | undefined, string, IWidgetOptions<unknown>>
| WidgetTypeMap[Type] {
(this as any)._tempWidth = this.size[0];
return super.addWidget(type, name, value, callback, options);
}
override removeWidget(widget: IBaseWidget | IWidget | number | undefined): void {
(this as any)._tempWidth = this.size[0];
super.removeWidget(widget);
}
override computeSize(out: Vector2) {
let size = super.computeSize(out);
if ((this as any)._tempWidth) {
size[0] = (this as any)._tempWidth;
// We sometimes get repeated calls to compute size, so debounce before clearing.
this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
this.debouncerTempWidth = setTimeout(() => {
(this as any)._tempWidth = null;
}, 32);
}
// If we're collapsed, then subtract the total calculated height of the other input slots.
if (this.properties["collapse_connections"]) {
const rows = Math.max(this.inputs?.length || 0, this.outputs?.length || 0, 1) - 1;
size[1] = size[1] - rows * LiteGraph.NODE_SLOT_HEIGHT;
}
setTimeout(() => {
this.graph?.setDirtyCanvas(true, true);
}, 16);
return size;
}
/**
* When we connect our output, check our inputs and make sure we're not trying to connect a loop.
*/
override onConnectOutput(
outputIndex: number,
inputType: string | -1,
inputSlot: INodeInputSlot,
inputNode: TLGraphNode,
inputIndex: number,
): boolean {
let canConnect = true;
if (super.onConnectOutput) {
canConnect = super.onConnectOutput(outputIndex, inputType, inputSlot, inputNode, inputIndex);
}
if (canConnect) {
const nodes = getConnectedInputNodes(this); // We want passthrough nodes, since they will loop.
if (nodes.includes(inputNode)) {
alert(
`Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
`a situation that could create a time paradox, the results of which could cause a ` +
`chain reaction that would unravel the very fabric of the space time continuum, ` +
`and destroy the entire universe!`,
);
canConnect = false;
}
}
return canConnect;
}
override onConnectInput(
inputIndex: number,
outputType: string | -1,
outputSlot: INodeOutputSlot,
outputNode: TLGraphNode,
outputIndex: number,
): boolean {
let canConnect = true;
if (super.onConnectInput) {
canConnect = super.onConnectInput(
inputIndex,
outputType,
outputSlot,
outputNode,
outputIndex,
);
}
if (canConnect) {
const nodes = getConnectedOutputNodes(this); // We want passthrough nodes, since they will loop.
if (nodes.includes(outputNode)) {
alert(
`Whoa, whoa, whoa. You've just tried to create a connection that loops back on itself, ` +
`a situation that could create a time paradox, the results of which could cause a ` +
`chain reaction that would unravel the very fabric of the space time continuum, ` +
`and destroy the entire universe!`,
);
canConnect = false;
}
}
return canConnect;
}
/**
* If something is dropped on us, just add it to the bottom. onConnectInput should already cancel
* if it's disallowed.
*/
override connectByTypeOutput(
slot: number | string,
sourceNode: TLGraphNode,
sourceSlotType: ISlotType,
optsIn?: ConnectByTypeOptions,
): LLink | null {
const lastInput = this.inputs[this.inputs.length - 1];
if (!lastInput?.link && lastInput?.type === "*") {
var sourceSlot = sourceNode.findOutputSlotByType(sourceSlotType, false, true);
return sourceNode.connect(sourceSlot, this, slot);
}
return super.connectByTypeOutput(slot, sourceNode, sourceSlotType, optsIn);
}
static override setUp() {
super.setUp();
addConnectionLayoutSupport(this, app, [
["Left", "Right"],
["Right", "Left"],
]);
addMenuItem(this, app, {
name: (node) =>
`${node.properties?.["collapse_connections"] ? "Show" : "Collapse"} Connections`,
property: "collapse_connections",
prepareValue: (_value, node) => !node.properties?.["collapse_connections"],
callback: (_node) => {
app.canvas.getCurrentGraph()?.setDirtyCanvas(true, true);
},
});
}
}
// Ok, hack time! LGraphNode's connectByType is powerful, but for our nodes, that have multiple "*"
// input types, it seems it just takes the first one, and disconnects it. I'd rather we don't do
// that and instead take the next free one. If that doesn't work, then we'll give it to the old
// method.
const oldLGraphNodeConnectByType = LGraphNode.prototype.connectByType;
LGraphNode.prototype.connectByType = function connectByType(
slot: number | string,
targetNode: TLGraphNode,
targetSlotType: ISlotType,
optsIn?: ConnectByTypeOptions,
): LLink | null {
// If we're dropping on a node, and the last input is free and an "*" type, then connect there
// first...
if (targetNode.inputs) {
for (const [index, input] of targetNode.inputs.entries()) {
if (!input.link && input.type === "*") {
this.connect(slot, targetNode, index);
return null;
}
}
}
return (
(oldLGraphNodeConnectByType &&
oldLGraphNodeConnectByType.call(this, slot, targetNode, targetSlotType, optsIn)) ||
null
);
};
+522
View File
@@ -0,0 +1,522 @@
import type {
ComfyNodeDef,
IWidget,
LGraphCanvas,
IContextMenuValue,
IFoundSlot,
LGraphEventMode,
LGraphNodeConstructor,
ISerialisedNode,
IBaseWidget,
} from "@comfyorg/frontend";
import type {RgthreeBaseServerNodeConstructor} from "typings/rgthree.js";
import {app} from "scripts/app.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
import {LogLevel, rgthree} from "./rgthree.js";
import {addHelpMenuItem} from "./utils.js";
import {RgthreeHelpDialog} from "rgthree/common/dialog.js";
import {
importIndividualNodesInnerOnDragDrop,
importIndividualNodesInnerOnDragOver,
} from "./feature_import_individual_nodes.js";
import {
defineProperty,
findPrototypePropertyDescriptor,
moveArrayItem,
} from "rgthree/common/shared_utils.js";
/**
* A base node with standard methods, directly extending the LGraphNode.
* This can be used for ui-nodes and a further base for server nodes.
*/
export abstract class RgthreeBaseNode extends LGraphNode {
/**
* Action strings that can be exposed and triggered from other nodes, like Fast Actions Button.
*/
static exposedActions: string[] = [];
static override title: string = "__NEED_CLASS_TITLE__";
static override type: string = "__NEED_CLASS_TYPE__";
static override category = "rgthree";
static _category = "rgthree"; // `category` seems to get reset by comfy, so reset to this after.
/** Our constructor ensures there's a widget array, so we get rid of the nullability. */
override widgets!: IWidget[];
/**
* The comfyClass is property ComfyUI and extensions may care about, even through it is only for
* server nodes. RgthreeBaseServerNode below overrides this with the expected value and we just
* set it here so extensions that are none the wiser don't break on some unchecked string method
* call on an undefined calue.
*/
override comfyClass: string = "__NEED_COMFY_CLASS__";
/** Used by the ComfyUI-Manager badge. */
readonly nickname = "rgthree";
/** Are we a virtual node? */
override readonly isVirtualNode: boolean = false;
/** Are we able to be dropped on (if config is enabled too). */
isDropEnabled = false;
/** A state member determining if we're currently removed. */
removed = false;
/** A state member determining if we're currently "configuring."" */
configuring = false;
/** A temporary width value that can be used to ensure compute size operates correctly. */
_tempWidth = 0;
/** Private Mode member so we can override the setter/getter and call an `onModeChange`. */
private rgthree_mode?: LGraphEventMode;
/** An internal bool set when `onConstructed` is run. */
private __constructed__ = false;
/** The help dialog. */
private helpDialog: RgthreeHelpDialog | null = null;
constructor(title = RgthreeBaseNode.title, skipOnConstructedCall = true) {
super(title);
if (title == "__NEED_CLASS_TITLE__") {
throw new Error("RgthreeBaseNode needs overrides.");
}
// Ensure these exist since some other extensions will break in their onNodeCreated.
this.widgets = this.widgets || [];
this.properties = this.properties || {};
// Some checks we want to do after we're constructed, looking that data is set correctly and
// that our base's `onConstructed` was called (if not, set a DEV warning).
setTimeout(() => {
// Check we have a comfyClass defined.
if (this.comfyClass == "__NEED_COMFY_CLASS__") {
throw new Error("RgthreeBaseNode needs a comfy class override.");
}
if (this.constructor.type == "__NEED_CLASS_TYPE__") {
throw new Error("RgthreeBaseNode needs overrides.");
}
// Ensure we've called onConstructed before we got here.
this.checkAndRunOnConstructed();
});
// Newer ComfyUI frontends track `mode` through an accessor on LGraphNode (backed by
// `_state.mode`, which is what gets serialized), so our override must delegate to it.
// On legacy frontends `mode` is a plain data property and this is null.
const protoMode = findPrototypePropertyDescriptor(this, "mode");
const readMode = (): LGraphEventMode | undefined =>
protoMode?.get ? protoMode.get.call(this) : this.rgthree_mode;
defineProperty(this, "mode", {
get: () => readMode(),
set: (mode: LGraphEventMode) => {
const oldMode = readMode();
if (protoMode?.set) {
// Newer frontends: write through so `_state.mode` (used by serialization) stays in sync.
protoMode.set.call(this, mode);
} else {
// Legacy frontends: keep the original behavior.
this.rgthree_mode = mode;
}
if (oldMode != mode) {
this.onModeChange(oldMode, mode);
}
},
});
}
private checkAndRunOnConstructed() {
if (!this.__constructed__) {
this.onConstructed();
const [n, v] = rgthree.logger.logParts(
LogLevel.DEV,
`[RgthreeBaseNode] Child class did not call onConstructed for "${this.type}.`,
);
console[n]?.(...v);
}
return this.__constructed__;
}
override onDragOver(e: DragEvent): boolean {
if (!this.isDropEnabled) return false;
return importIndividualNodesInnerOnDragOver(this, e);
}
override async onDragDrop(e: DragEvent): Promise<boolean> {
if (!this.isDropEnabled) return false;
return importIndividualNodesInnerOnDragDrop(this, e);
}
/**
* When a node is finished with construction, we must call this. Failure to do so will result in
* an error message from the timeout in this base class. This is broken out and becomes the
* responsibility of the child class because
*/
onConstructed() {
if (this.__constructed__) return false;
// This is kinda a hack, but if this.type is still null, then set it to undefined to match.
this.type = this.type ?? undefined;
this.__constructed__ = true;
rgthree.invokeExtensionsAsync("nodeCreated", this);
return this.__constructed__;
}
override configure(info: ISerialisedNode): void {
this.configuring = true;
super.configure(info);
// Fix https://github.com/comfyanonymous/ComfyUI/issues/1448 locally.
// Can removed when fixed and adopted.
for (const w of this.widgets || []) {
w.last_y = w.last_y || 0;
}
this.configuring = false;
}
/**
* Override clone for, at the least, deep-copying properties.
*/
override clone() {
const cloned = super.clone()!;
// This is wild, but LiteGraph doesn't deep clone data, so we will. We'll use structured clone,
// which most browsers in 2022 support, but but we'll check.
if (cloned?.properties && !!window.structuredClone) {
cloned.properties = structuredClone(cloned.properties);
}
// [🤮] https://github.com/Comfy-Org/ComfyUI_frontend/issues/5037
// ComfyUI started throwing errors when some of our nodes wanted to remove inputs when cloning
// (like our dynamic inputs) because the disconnect method that's automatically called assumes
// there should be a graph. For now, I _think_ we can simply assign the current graph to avoid
// the error, which would then be overwritten when placed...
cloned.graph = this.graph;
return cloned;
}
/** When a mode change, we want all connected nodes to match. */
onModeChange(from: LGraphEventMode | undefined, to: LGraphEventMode) {
// Override
}
/**
* Given a string, do something. At the least, handle any `exposedActions` that may be called and
* passed into from other nodes, like Fast Actions Button
*/
async handleAction(action: string) {
action; // No-op. Should be overridden but OK if not.
}
/**
* This didn't exist in LiteGraph/Comfy, but now it's added. Ours was a bit more flexible, though.
*/
override removeWidget(widget: IBaseWidget | IWidget | number | undefined): void {
if (typeof widget === "number") {
widget = this.widgets[widget];
}
if (!widget) return;
// Comfy added their own removeWidget, but it's not fully rolled out to stable, so keep our
// original implementation.
// TODO: Actually, scratch that. The ComfyUI impl doesn't call widtget.onRemove?.() and so
// we shouldn't switch to it yet. See: https://github.com/Comfy-Org/ComfyUI_frontend/issues/5090
const canUseComfyUIRemoveWidget = false;
if (canUseComfyUIRemoveWidget && typeof super.removeWidget === "function") {
super.removeWidget(widget as IBaseWidget);
} else {
const index = this.widgets.indexOf(widget as IWidget);
if (index > -1) {
this.widgets.splice(index, 1);
}
widget.onRemove?.();
}
}
/**
* Replaces an existing widget.
*/
replaceWidget(widgetOrSlot: IWidget | number | undefined, newWidget: IWidget) {
let index = null;
if (widgetOrSlot) {
index = typeof widgetOrSlot === "number" ? widgetOrSlot : this.widgets.indexOf(widgetOrSlot);
this.removeWidget(this.widgets[index]!);
}
index = index != null ? index : this.widgets.length - 1;
if (this.widgets.includes(newWidget)) {
moveArrayItem(this.widgets, newWidget, index);
} else {
this.widgets.splice(index, 0, newWidget);
}
}
/**
* A default version of the logive when a node does not set `getSlotMenuOptions`. This is
* necessary because child nodes may want to define getSlotMenuOptions but LiteGraph then won't do
* it's default logic. This bakes it so child nodes can call this instead (and this doesn't set
* getSlotMenuOptions for all child nodes in case it doesn't exist).
*/
defaultGetSlotMenuOptions(slot: IFoundSlot): IContextMenuValue[] {
const menu_info: IContextMenuValue[] = [];
if (slot?.output?.links?.length) {
menu_info.push({content: "Disconnect Links", slot});
}
let inputOrOutput = slot.input || slot.output;
if (inputOrOutput) {
if (inputOrOutput.removable) {
menu_info.push(
inputOrOutput.locked ? {content: "Cannot remove"} : {content: "Remove Slot", slot},
);
}
if (!inputOrOutput.nameLocked) {
menu_info.push({content: "Rename Slot", slot});
}
}
return menu_info;
}
override onRemoved(): void {
super.onRemoved?.();
this.removed = true;
}
static setUp<T extends RgthreeBaseNode>(...args: any[]) {
// No-op.
}
/**
* A function to provide help text to be overridden.
*/
getHelp() {
return "";
}
showHelp() {
const help = this.getHelp() || (this.constructor as any).help;
if (help) {
this.helpDialog = new RgthreeHelpDialog(this, help).show();
this.helpDialog.addEventListener("close", (e) => {
this.helpDialog = null;
});
}
}
override onKeyDown(event: KeyboardEvent): void {
KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
if (event.key == "?" && !this.helpDialog) {
this.showHelp();
}
}
override onKeyUp(event: KeyboardEvent): void {
KEY_EVENT_SERVICE.handleKeyDownOrUp(event);
}
override getExtraMenuOptions(
canvas: LGraphCanvas,
options: (IContextMenuValue<unknown> | null)[],
): (IContextMenuValue<unknown> | null)[] {
// Some other extensions override getExtraMenuOptions on the nodeType as it comes through from
// the server, so we can call out to that if we don't have our own.
if (super.getExtraMenuOptions) {
super.getExtraMenuOptions?.apply(this, [canvas, options]);
} else if (this.constructor.nodeType?.prototype?.getExtraMenuOptions) {
this.constructor.nodeType?.prototype?.getExtraMenuOptions?.apply(this, [canvas, options]);
}
// If we have help content, then add a menu item.
const help = this.getHelp() || (this.constructor as any).help;
if (help) {
addHelpMenuItem(this, help, options);
}
return [];
}
}
/**
* A virtual node. Right now, this is just a wrapper for RgthreeBaseNode (which was the initial
* base virtual node).
*/
export class RgthreeBaseVirtualNode extends RgthreeBaseNode {
override isVirtualNode = true;
constructor(title = RgthreeBaseNode.title) {
super(title, false);
}
static override setUp() {
if (!this.type) {
throw new Error(`Missing type for RgthreeBaseVirtualNode: ${this.title}`);
}
LiteGraph.registerNodeType(this.type, this);
if (this._category) {
this.category = this._category;
}
}
}
/**
* A base node with standard methods, extending the LGraphNode.
* This is somewhat experimental, but if comfyui is going to keep breaking widgets and inputs, it
* seems safer than NOT overriding.
*/
export class RgthreeBaseServerNode extends RgthreeBaseNode {
static nodeType: LGraphNodeConstructor | null = null;
static nodeDef: ComfyNodeDef | null = null;
// Drop is enabled by default for server nodes.
override isDropEnabled = true;
constructor(title: string) {
super(title, true);
this.serialize_widgets = true;
this.setupFromServerNodeData();
this.onConstructed();
}
getWidgets() {
return ComfyWidgets;
}
/**
* This takes the server data and builds out the inputs, outputs and widgets. It's similar to the
* ComfyNode constructor in registerNodes in ComfyUI's app.js, but is more stable and thus
* shouldn't break as often when it modifyies widgets and types.
*/
async setupFromServerNodeData() {
const nodeDef = (this.constructor as any).nodeDef || (this.constructor as any).nodeData;
if (!nodeDef) {
throw Error("No node data");
}
// Necessary for serialization so Comfy backend can check types.
// Serialized as `class_type`. See app.js#graphToPrompt
this.comfyClass = nodeDef.name;
let inputs = nodeDef["input"]["required"];
if (nodeDef["input"]["optional"] != undefined) {
inputs = Object.assign({}, inputs, nodeDef["input"]["optional"]);
}
const WIDGETS = this.getWidgets();
const config: {minWidth: number; minHeight: number; widget?: null | {options: any}} = {
minWidth: 1,
minHeight: 1,
widget: null,
};
for (const inputName in inputs) {
const inputData = inputs[inputName];
const type = inputData[0];
// If we're forcing the input, just do it now and forget all that widget stuff.
// This is one of the differences from ComfyNode and provides smoother experience for inputs
// that are going to remain inputs anyway.
// Also, it fixes https://github.com/comfyanonymous/ComfyUI/issues/1404 (for rgthree nodes)
if (inputData[1]?.forceInput) {
this.addInput(inputName, type);
} else {
let widgetCreated = true;
if (Array.isArray(type)) {
// Enums
Object.assign(config, WIDGETS.COMBO(this, inputName, inputData, app) || {});
} else if (`${type}:${inputName}` in WIDGETS) {
// Support custom widgets by Type:Name
Object.assign(
config,
WIDGETS[`${type}:${inputName}`]!(this, inputName, inputData, app) || {},
);
} else if (type in WIDGETS) {
// Standard type widgets
Object.assign(config, WIDGETS[type]!(this, inputName, inputData, app) || {});
} else {
// Node connection inputs
this.addInput(inputName, type);
widgetCreated = false;
}
// Don't actually need this right now, but ported it over from ComfyWidget.
if (widgetCreated && inputData[1]?.forceInput && config?.widget) {
if (!config.widget.options) config.widget.options = {};
config.widget.options.forceInput = inputData[1].forceInput;
}
if (widgetCreated && inputData[1]?.defaultInput && config?.widget) {
if (!config.widget.options) config.widget.options = {};
config.widget.options.defaultInput = inputData[1].defaultInput;
}
}
}
for (const o in nodeDef["output"]) {
let output = nodeDef["output"][o];
if (output instanceof Array) output = "COMBO";
const outputName = nodeDef["output_name"][o] || output;
const outputShape = nodeDef["output_is_list"][o]
? LiteGraph.GRID_SHAPE
: LiteGraph.CIRCLE_SHAPE;
this.addOutput(outputName, output, {shape: outputShape});
}
const s = this.computeSize();
// Sometime around v1.12.6 this broke as `minWidth` and `minHeight` were being explicitly set
// to `undefined` in the above Object.assign call (specifically for `WIDGETS[INT]`. We can avoid
// that by ensureing we're at a number in that case.
// See https://github.com/Comfy-Org/ComfyUI_frontend/issues/3045
s[0] = Math.max(config.minWidth ?? 1, s[0] * 1.5);
s[1] = Math.max(config.minHeight ?? 1, s[1]);
this.size = s;
this.serialize_widgets = true;
}
static __registeredForOverride__: boolean = false;
static registerForOverride(
comfyClass: typeof LGraphNode,
nodeData: ComfyNodeDef,
rgthreeClass: RgthreeBaseServerNodeConstructor,
) {
if (OVERRIDDEN_SERVER_NODES.has(comfyClass)) {
throw Error(
`Already have a class to override ${
comfyClass.type || comfyClass.name || comfyClass.title
}`,
);
}
OVERRIDDEN_SERVER_NODES.set(comfyClass, rgthreeClass);
// Mark the rgthreeClass as `__registeredForOverride__` because ComfyUI will repeatedly call
// this and certain setups will only want to setup once (like adding context menus, etc).
if (!rgthreeClass.__registeredForOverride__) {
rgthreeClass.__registeredForOverride__ = true;
rgthreeClass.nodeType = comfyClass;
rgthreeClass.nodeData = nodeData;
// At some point, ComfyUI defined a `nodeData` that may or may not be the nodeDef. I think
// it's just lazily typed, but just in case, we'll compy the nodeData to our own field to use.
rgthreeClass.nodeDef = nodeData;
rgthreeClass.onRegisteredForOverride(comfyClass, rgthreeClass);
}
}
static onRegisteredForOverride(comfyClass: any, rgthreeClass: any) {
// To be overridden
}
}
/**
* Keeps track of the rgthree-comfy nodes that come from the server (and want to be ComfyNodes) that
* we override into a own, more flexible and cleaner nodes.
*/
const OVERRIDDEN_SERVER_NODES = new Map<any, any>();
const oldregisterNodeType = LiteGraph.registerNodeType;
/**
* ComfyUI calls registerNodeType with its ComfyNode, but we don't trust that will remain stable, so
* we need to identify it, intercept it, and supply our own class for the node.
*/
LiteGraph.registerNodeType = async function (nodeId: string, baseClass: any) {
const clazz = OVERRIDDEN_SERVER_NODES.get(baseClass) || baseClass;
if (clazz !== baseClass) {
const classLabel = clazz.type || clazz.name || clazz.title;
const [n, v] = rgthree.logger.logParts(
LogLevel.DEBUG,
`${nodeId}: replacing default ComfyNode implementation with custom ${classLabel} class.`,
);
console[n]?.(...v);
// Note, we don't currently call our rgthree.invokeExtensionsAsync w/ beforeRegisterNodeDef as
// this runs right after that. However, this does mean that extensions cannot actually change
// anything about overriden server rgthree nodes in their beforeRegisterNodeDef (as when comfy
// calls it, it's for the wrong ComfyNode class). Calling it here, however, would re-run
// everything causing more issues than not. If we wanted to support beforeRegisterNodeDef then
// it would mean rewriting ComfyUI's registerNodeDef which, frankly, is not worth it.
}
return oldregisterNodeType.call(LiteGraph, nodeId, clazz);
};
+99
View File
@@ -0,0 +1,99 @@
import type {INodeOutputSlot, LGraphNode} from "@comfyorg/frontend";
import {rgthree} from "./rgthree.js";
import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
import {
PassThroughFollowing,
getConnectedInputNodes,
getConnectedInputNodesAndFilterPassThroughs,
shouldPassThrough,
} from "./utils.js";
/**
* Base collector node that monitors changing inputs and outputs.
*/
export class BaseCollectorNode extends BaseAnyInputConnectedNode {
/**
* We only want to show nodes through re_route nodes, other pass through nodes show each input.
*/
override readonly inputsPassThroughFollowing: PassThroughFollowing =
PassThroughFollowing.REROUTE_ONLY;
readonly logger = rgthree.newLogSession("[BaseCollectorNode]");
constructor(title?: string) {
super(title);
}
override clone() {
const cloned = super.clone()!;
return cloned;
}
override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
return false; // No-op, no widgets.
}
/**
* When we connect an input, check to see if it's already connected and cancel it.
*/
override onConnectInput(
inputIndex: number,
outputType: string | -1,
outputSlot: INodeOutputSlot,
outputNode: LGraphNode,
outputIndex: number,
): boolean {
let canConnect = super.onConnectInput(
inputIndex,
outputType,
outputSlot,
outputNode,
outputIndex,
);
if (canConnect) {
const allConnectedNodes = getConnectedInputNodes(this); // We want passthrough nodes, since they will loop.
const nodesAlreadyInSlot = getConnectedInputNodes(this, undefined, inputIndex);
if (allConnectedNodes.includes(outputNode)) {
// If we're connecting to the same slot, then allow it by replacing the one we have.
// const slotsOriginNode = getOriginNodeByLink(this.inputs[inputIndex]?.link);
const [n, v] = this.logger.debugParts(
`${outputNode.title} is already connected to ${this.title}.`,
);
console[n]?.(...v);
if (nodesAlreadyInSlot.includes(outputNode)) {
const [n, v] = this.logger.debugParts(
`... but letting it slide since it's for the same slot.`,
);
console[n]?.(...v);
} else {
canConnect = false;
}
}
if (canConnect && shouldPassThrough(outputNode, PassThroughFollowing.REROUTE_ONLY)) {
const connectedNode = getConnectedInputNodesAndFilterPassThroughs(
outputNode,
undefined,
undefined,
PassThroughFollowing.REROUTE_ONLY,
)[0];
if (connectedNode && allConnectedNodes.includes(connectedNode)) {
// If we're connecting to the same slot, then allow it by replacing the one we have.
const [n, v] = this.logger.debugParts(
`${connectedNode.title} is already connected to ${this.title}.`,
);
console[n]?.(...v);
if (nodesAlreadyInSlot.includes(connectedNode)) {
const [n, v] = this.logger.debugParts(
`... but letting it slide since it's for the same slot.`,
);
console[n]?.(...v);
} else {
canConnect = false;
}
}
}
}
return canConnect;
}
}
+106
View File
@@ -0,0 +1,106 @@
import type {LGraphNode, IWidget} from "@comfyorg/frontend";
import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
import {changeModeOfNodes, PassThroughFollowing} from "./utils.js";
import {wait} from "rgthree/common/shared_utils.js";
export class BaseNodeModeChanger extends BaseAnyInputConnectedNode {
override readonly inputsPassThroughFollowing: PassThroughFollowing = PassThroughFollowing.ALL;
static collapsible = false;
override isVirtualNode = true;
// These Must be overriden
readonly modeOn: number = -1;
readonly modeOff: number = -1;
static "@toggleRestriction" = {
type: "combo",
values: ["default", "max one", "always one"],
};
constructor(title?: string) {
super(title);
this.properties["toggleRestriction"] = "default";
}
override onConstructed(): boolean {
wait(10).then(() => {
if (this.modeOn < 0 || this.modeOff < 0) {
throw new Error("modeOn and modeOff must be overridden.");
}
});
this.addOutput("OPT_CONNECTION", "*");
return super.onConstructed();
}
override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
let changed = false;
for (const [index, node] of linkedNodes.entries()) {
let widget: IWidget | undefined = this.widgets && this.widgets[index];
if (!widget) {
// When we add a widget, litegraph is going to mess up the size, so we
// store it so we can retrieve it in computeSize. Hacky..
(this as any)._tempWidth = this.size[0];
widget = this.addWidget("toggle", "", false, "", {on: "yes", off: "no"}) as IWidget;
changed = true;
}
if (node) {
changed = this.setWidget(widget, node) || changed;
}
}
if (this.widgets && this.widgets.length > linkedNodes.length) {
this.widgets.length = linkedNodes.length;
changed = true;
}
return changed;
}
private setWidget(widget: IWidget, linkedNode: LGraphNode, forceValue?: boolean) {
let changed = false;
const value = forceValue == null ? linkedNode.mode === this.modeOn : forceValue;
let name = `Enable ${linkedNode.title}`;
// Need to set initally
if (widget.name !== name) {
widget.name = `Enable ${linkedNode.title}`;
widget.options = {on: "yes", off: "no"};
widget.value = value;
(widget as any).doModeChange = (forceValue?: boolean, skipOtherNodeCheck?: boolean) => {
let newValue = forceValue == null ? linkedNode.mode === this.modeOff : forceValue;
if (skipOtherNodeCheck !== true) {
if (newValue && (this.properties?.["toggleRestriction"] as string)?.includes(" one")) {
for (const widget of this.widgets) {
(widget as any).doModeChange(false, true);
}
} else if (!newValue && this.properties?.["toggleRestriction"] === "always one") {
newValue = this.widgets.every((w) => !w.value || w === widget);
}
}
changeModeOfNodes(linkedNode, (newValue ? this.modeOn : this.modeOff))
widget.value = newValue;
};
widget.callback = () => {
(widget as any).doModeChange();
};
changed = true;
}
if (forceValue != null) {
const newMode = (forceValue ? this.modeOn : this.modeOff) as 1 | 2 | 3 | 4;
if (linkedNode.mode !== newMode) {
changeModeOfNodes(linkedNode, newMode);
changed = true;
}
}
return changed;
}
forceWidgetOff(widget: IWidget, skipOtherNodeCheck?: boolean) {
(widget as any).doModeChange(false, skipOtherNodeCheck);
}
forceWidgetOn(widget: IWidget, skipOtherNodeCheck?: boolean) {
(widget as any).doModeChange(true, skipOtherNodeCheck);
}
forceWidgetToggle(widget: IWidget, skipOtherNodeCheck?: boolean) {
(widget as any).doModeChange(!widget.value, skipOtherNodeCheck);
}
}
+366
View File
@@ -0,0 +1,366 @@
import type {
ComfyNodeDef,
LLink,
LGraphNode,
INodeOutputSlot,
INodeInputSlot,
ISerialisedNode,
IComboWidget,
IBaseWidget,
} from "@comfyorg/frontend";
import {api} from "scripts/api.js";
import {wait} from "rgthree/common/shared_utils.js";
import {rgthree} from "./rgthree.js";
/** Wraps a node instance keeping closure without mucking the finicky types. */
export class PowerPrompt {
readonly isSimple: boolean;
readonly node: LGraphNode;
readonly promptEl: HTMLTextAreaElement;
nodeData: ComfyNodeDef;
readonly combos: {[key: string]: IComboWidget} = {};
readonly combosValues: {[key: string]: string[]} = {};
boundOnFreshNodeDefs!: (event: CustomEvent) => void;
private configuring = false;
constructor(node: LGraphNode, nodeData: ComfyNodeDef) {
this.node = node;
this.node.properties = this.node.properties || {};
this.node.properties["combos_filter"] = "";
this.nodeData = nodeData;
this.isSimple = this.nodeData.name.includes("Simple");
this.promptEl = (node.widgets![0]! as any).inputEl;
this.addAndHandleKeyboardLoraEditWeight();
this.patchNodeRefresh();
const oldConfigure = this.node.configure;
this.node.configure = (info: ISerialisedNode) => {
this.configuring = true;
oldConfigure?.apply(this.node, [info]);
this.configuring = false;
};
const oldOnConnectionsChange = this.node.onConnectionsChange;
this.node.onConnectionsChange = (
type: number,
slotIndex: number,
isConnected: boolean,
link_info: LLink,
_ioSlot: INodeOutputSlot | INodeInputSlot,
) => {
oldOnConnectionsChange?.apply(this.node, [type, slotIndex, isConnected, link_info, _ioSlot]);
this.onNodeConnectionsChange(type, slotIndex, isConnected, link_info, _ioSlot);
};
const oldOnConnectInput = this.node.onConnectInput;
this.node.onConnectInput = (
inputIndex: number,
outputType: INodeOutputSlot["type"],
outputSlot: INodeOutputSlot,
outputNode: LGraphNode,
outputIndex: number,
) => {
let canConnect = true;
if (oldOnConnectInput) {
canConnect = oldOnConnectInput.apply(this.node, [
inputIndex,
outputType,
outputSlot,
outputNode,
outputIndex,
]);
}
return (
this.configuring ||
!!rgthree.loadingApiJson ||
(canConnect && !this.node.inputs[inputIndex]!.disabled)
);
};
const oldOnConnectOutput = this.node.onConnectOutput;
this.node.onConnectOutput = (
outputIndex: number,
inputType: INodeInputSlot["type"],
inputSlot: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number,
) => {
let canConnect = true;
if (oldOnConnectOutput) {
canConnect = oldOnConnectOutput?.apply(this.node, [
outputIndex,
inputType,
inputSlot,
inputNode,
inputIndex,
]);
}
return (
this.configuring ||
!!rgthree.loadingApiJson ||
(canConnect && !this.node.outputs[outputIndex]!.disabled)
);
};
const onPropertyChanged = this.node.onPropertyChanged;
this.node.onPropertyChanged = (property: string, value: any, prevValue: any) => {
const v = onPropertyChanged && onPropertyChanged.call(this.node, property, value, prevValue);
if (property === "combos_filter") {
this.refreshCombos(this.nodeData);
}
return v ?? true;
};
// Strip all widgets but prompt (we'll re-add them in refreshCombos)
// this.node.widgets.splice(1);
for (let i = this.node.widgets!.length - 1; i >= 0; i--) {
if (this.shouldRemoveServerWidget(this.node.widgets![i]!)) {
this.node.widgets!.splice(i, 1);
}
}
this.refreshCombos(nodeData);
setTimeout(() => {
this.stabilizeInputsOutputs();
}, 32);
}
/**
* Cleans up optional out puts when we don't have the optional input. Purely a vanity function.
*/
onNodeConnectionsChange(
_type: number,
_slotIndex: number,
_isConnected: boolean,
_linkInfo: LLink,
_ioSlot: INodeOutputSlot | INodeInputSlot,
) {
this.stabilizeInputsOutputs();
}
private stabilizeInputsOutputs() {
// If we are currently "configuring" then skip this stabilization. The connected nodes may
// not yet be configured.
if (this.configuring || rgthree.loadingApiJson) {
return;
}
// If our first input is connected, then we can show the proper output.
const clipLinked = this.node.inputs.some((i) => i.name.includes("clip") && !!i.link);
const modelLinked = this.node.inputs.some((i) => i.name.includes("model") && !!i.link);
for (const output of this.node.outputs) {
const type = (output.type as string).toLowerCase();
if (type.includes("model")) {
output.disabled = !modelLinked;
} else if (type.includes("conditioning")) {
output.disabled = !clipLinked;
} else if (type.includes("clip")) {
output.disabled = !clipLinked;
} else if (type.includes("string")) {
// Our text prompt is always enabled, but let's color it so it stands out
// if the others are disabled. #7F7 is Litegraph's default.
output.color_off = "#7F7";
output.color_on = "#7F7";
}
if (output.disabled) {
// this.node.disconnectOutput(index);
}
}
}
onFreshNodeDefs(event: CustomEvent) {
this.refreshCombos(event.detail[this.nodeData.name]);
}
shouldRemoveServerWidget(widget: IBaseWidget) {
return (
widget.name?.startsWith("insert_") ||
widget.name?.startsWith("target_") ||
widget.name?.startsWith("crop_") ||
widget.name?.startsWith("values_")
);
}
refreshCombos(nodeData: ComfyNodeDef) {
this.nodeData = nodeData;
let filter: RegExp | null = null;
if ((this.node.properties["combos_filter"] as string)?.trim()) {
try {
filter = new RegExp((this.node.properties["combos_filter"] as string).trim(), "i");
} catch (e) {
console.error(`Could not parse "${filter}" for Regular Expression`, e);
filter = null;
}
}
// Add the combo for hidden inputs of nodeData
let data: {[key: string]: string[]} = Object.assign(
{},
this.nodeData.input?.optional || {},
this.nodeData.input?.hidden || {},
);
for (const [key, value] of Object.entries(data)) {
//Object.entries(this.nodeData.input?.hidden || {})) {
if (Array.isArray(value[0])) {
let values = value[0] as string[];
if (key.startsWith("insert")) {
values = filter
? values.filter(
(v, i) => i < 1 || (i == 1 && v.match(/^disable\s[a-z]/i)) || filter?.test(v),
)
: values;
const shouldShow =
values.length > 2 || (values.length > 1 && !values[1]!.match(/^disable\s[a-z]/i));
if (shouldShow) {
if (!this.combos[key]) {
this.combos[key] = this.node.addWidget(
"combo",
key,
values[0]!,
(selected: string) => {
if (selected !== values[0] && !selected.match(/^disable\s[a-z]/i)) {
// We wait a frame because if we use a keydown event to call, it'll wipe out
// the selection.
wait().then(() => {
if (key.includes("embedding")) {
this.insertSelectionText(`embedding:${selected}`);
} else if (key.includes("saved")) {
this.insertSelectionText(
this.combosValues[`values_${key}`]![values.indexOf(selected)]!,
);
} else if (key.includes("lora")) {
this.insertSelectionText(`<lora:${selected}:1.0>`);
}
this.combos[key]!.value = values[0]!;
});
}
},
{
values,
serialize: true, // Don't include this in prompt.
},
) as IComboWidget;
(this.combos[key]! as any).oldComputeSize = this.combos[key]!.computeSize;
let node = this.node;
this.combos[key]!.computeSize = function (width: number) {
const size = (this as any).oldComputeSize?.(width) || [
width,
LiteGraph.NODE_WIDGET_HEIGHT,
];
if (this === node.widgets![node.widgets!.length - 1]) {
size[1] += 10;
}
return size;
};
}
this.combos[key]!.options!.values = values;
this.combos[key]!.value = values[0]!;
} else if (!shouldShow && this.combos[key]) {
this.node.widgets!.splice(this.node.widgets!.indexOf(this.combos[key]!), 1);
delete this.combos[key];
}
} else if (key.startsWith("values")) {
this.combosValues[key] = values;
}
}
}
}
insertSelectionText(text: string) {
if (!this.promptEl) {
console.error("Asked to insert text, but no textbox found.");
return;
}
let prompt = this.promptEl.value;
// Use selectionEnd as the split; if we have highlighted text, then we likely don't want to
// overwrite it (we could have just deleted it more easily).
let first = prompt.substring(0, this.promptEl.selectionEnd).replace(/ +$/, "");
first = first + (["\n"].includes(first[first.length - 1]!) ? "" : first.length ? " " : "");
let second = prompt.substring(this.promptEl.selectionEnd).replace(/^ +/, "");
second = (["\n"].includes(second[0]!) ? "" : second.length ? " " : "") + second;
this.promptEl.value = first + text + second;
this.promptEl.focus();
this.promptEl.selectionStart = first.length;
this.promptEl.selectionEnd = first.length + text.length;
}
/**
* Adds a keydown event listener to our prompt so we can see if we're using the
* ctrl/cmd + up/down arrows shortcut. This kind of competes with the core extension
* "Comfy.EditAttention" but since that only handles parenthesis and listens on window, we should
* be able to intercept and cancel the bubble if we're doing the same action within the lora tag.
*/
addAndHandleKeyboardLoraEditWeight() {
this.promptEl.addEventListener("keydown", (event: KeyboardEvent) => {
// If we're not doing a ctrl/cmd + arrow key, then bail.
if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) return;
if (!event.ctrlKey && !event.metaKey) return;
// Unfortunately, we can't see Comfy.EditAttention delta in settings, so we hardcode to 0.01.
// We can acutally do better too, let's make it .1 by default, and .01 if also holding shift.
const delta = event.shiftKey ? 0.01 : 0.1;
let start = this.promptEl.selectionStart;
let end = this.promptEl.selectionEnd;
let fullText = this.promptEl.value;
let selectedText = fullText.substring(start, end);
// We don't care about fully rewriting Comfy.EditAttention, we just want to see if our
// selected text is a lora, which will always start with "<lora:". So work backwards until we
// find something that we know can't be a lora, or a "<".
if (!selectedText) {
const stopOn = "<>()\r\n\t"; // Allow spaces, since they can be in the filename
if (fullText[start] == ">") {
start -= 2;
end -= 2;
}
if (fullText[end - 1] == "<") {
start += 2;
end += 2;
}
while (!stopOn.includes(fullText[start]!) && start > 0) {
start--;
}
while (!stopOn.includes(fullText[end - 1]!) && end < fullText.length) {
end++;
}
selectedText = fullText.substring(start, end);
}
// Bail if this isn't a lora.
if (!selectedText.startsWith("<lora:") || !selectedText.endsWith(">")) {
return;
}
let weight = Number(selectedText.match(/:(-?\d*(\.\d*)?)>$/)?.[1]) ?? 1;
weight += event.key === "ArrowUp" ? delta : -delta;
const updatedText = selectedText.replace(/(:-?\d*(\.\d*)?)?>$/, `:${weight.toFixed(2)}>`);
// Handle the new value and cancel the bubble so Comfy.EditAttention doesn't also try.
this.promptEl.setRangeText(updatedText, start, end, "select");
event.preventDefault();
event.stopPropagation();
});
}
/**
* Patches over api.getNodeDefs in comfy's api.js to fire a custom event that we can listen to
* here and manually refresh our combos when a request comes in to fetch the node data; which
* only happens once at startup (but before custom nodes js runs), and then after clicking
* the "Refresh" button in the floating menu, which is what we care about.
*/
patchNodeRefresh() {
this.boundOnFreshNodeDefs = this.onFreshNodeDefs.bind(this);
api.addEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener);
const oldNodeRemoved = this.node.onRemoved;
this.node.onRemoved = () => {
oldNodeRemoved?.call(this.node);
api.removeEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener);
};
}
}
+163
View File
@@ -0,0 +1,163 @@
import type {
LGraph,
LGraphCanvas,
LGraphNode,
Point,
CanvasMouseEvent,
Subgraph,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
import {SERVICE as BOOKMARKS_SERVICE} from "./services/bookmarks_services.js";
import {NodeTypesString} from "./constants.js";
import {getClosestOrSelf, query} from "rgthree/common/utils_dom.js";
import {wait} from "rgthree/common/shared_utils.js";
import {findFromNodeForSubgraph} from "./utils.js";
/**
* A bookmark node. Can be placed anywhere in the workflow, and given a shortcut key that will
* navigate to that node, with it in the top-left corner.
*/
export class Bookmark extends RgthreeBaseVirtualNode {
static override type = NodeTypesString.BOOKMARK;
static override title = NodeTypesString.BOOKMARK;
override comfyClass = NodeTypesString.BOOKMARK;
// Really silly, but Litegraph assumes we have at least one input/output... so we need to
// counteract it's computeSize calculation by offsetting the start.
static slot_start_y = -20;
// LiteGraph adds mroe spacing than we want when calculating a nodes' `_collapsed_width`, so we'll
// override it with a setter and re-set it measured exactly as we want.
___collapsed_width: number = 0;
override isVirtualNode = true;
override serialize_widgets = true;
//@ts-ignore - TS Doesn't like us overriding a property with accessors but, too bad.
override get _collapsed_width() {
return this.___collapsed_width;
}
override set _collapsed_width(width: number) {
const canvas = app.canvas as LGraphCanvas;
const ctx = canvas.canvas.getContext("2d")!;
const oldFont = ctx.font;
ctx.font = canvas.title_text_font;
this.___collapsed_width = 40 + ctx.measureText(this.title).width;
ctx.font = oldFont;
}
readonly keypressBound;
constructor(title = Bookmark.title) {
super(title);
const nextShortcutChar = BOOKMARKS_SERVICE.getNextShortcut();
this.addWidget(
"text",
"shortcut_key",
nextShortcutChar,
(value: string, ...args) => {
value = value.trim()[0] || "1";
},
{
y: 8,
},
);
this.addWidget("number", "zoom", 1, (value: number) => {}, {
y: 8 + LiteGraph.NODE_WIDGET_HEIGHT + 4,
max: 2,
min: 0.5,
precision: 2,
});
this.keypressBound = this.onKeypress.bind(this);
this.title = "🔖";
this.onConstructed();
}
// override computeSize(out?: Vector2 | undefined): Vector2 {
// super.computeSize(out);
// const minHeight = (this.widgets?.length || 0) * (LiteGraph.NODE_WIDGET_HEIGHT + 4) + 16;
// this.size[1] = Math.max(minHeight, this.size[1]);
// }
get shortcutKey(): string {
return (this.widgets[0]?.value as string)?.toLocaleLowerCase() ?? "";
}
override onAdded(graph: LGraph): void {
KEY_EVENT_SERVICE.addEventListener("keydown", this.keypressBound as EventListener);
}
override onRemoved(): void {
KEY_EVENT_SERVICE.removeEventListener("keydown", this.keypressBound as EventListener);
}
onKeypress(event: CustomEvent<{originalEvent: KeyboardEvent}>) {
const originalEvent = event.detail.originalEvent;
const target = (originalEvent.target as HTMLElement)!;
if (getClosestOrSelf(target, 'input,textarea,[contenteditable="true"]')) {
return;
}
// Only the shortcut keys are held down, optionally including "shift".
if (KEY_EVENT_SERVICE.areOnlyKeysDown(this.widgets[0]!.value as string, true)) {
this.canvasToBookmark();
originalEvent.preventDefault();
originalEvent.stopPropagation();
}
}
/**
* Called from LiteGraph's `processMouseDown` after it would invoke the input box for the
* shortcut_key, so we check if it exists and then add our own event listener so we can track the
* keys down for the user. Note, blocks drag if the return is truthy.
*/
override onMouseDown(event: CanvasMouseEvent, pos: Point, graphCanvas: LGraphCanvas): boolean {
const input = query<HTMLInputElement>(".graphdialog > input.value");
if (input && input.value === this.widgets[0]?.value) {
input.addEventListener("keydown", (e) => {
// ComfyUI swallows keydown on inputs, so we need to call out to rgthree to use downkeys.
KEY_EVENT_SERVICE.handleKeyDownOrUp(e);
e.preventDefault();
e.stopPropagation();
input.value = Object.keys(KEY_EVENT_SERVICE.downKeys).join(" + ");
});
}
return false;
}
async canvasToBookmark() {
const canvas = app.canvas as LGraphCanvas;
if (this.graph !== app.canvas.getCurrentGraph()) {
const subgraph = this.graph as Subgraph;
// At some point, ComfyUI made a second param for openSubgraph which appears to be the node
// that id double-clicked on to open the subgraph. We don't have that in the bookmark, so
// we'll look for it. Note, that when opening the root graph, this will be null (since there's
// no such node). It seems to still navigate fine, though there's a console error about
// proxyWidgets or something..
const fromNode = findFromNodeForSubgraph(subgraph.id);
canvas.openSubgraph(subgraph, fromNode!);
await wait(16);
}
// ComfyUI seemed to break us again, but couldn't repro. No reason to not check, I guess.
// https://github.com/rgthree/rgthree-comfy/issues/71
if (canvas?.ds?.offset) {
canvas.ds.offset[0] = -this.pos[0] + 16;
canvas.ds.offset[1] = -this.pos[1] + 40;
}
if (canvas?.ds?.scale != null) {
canvas.ds.scale = Number(this.widgets[1]!.value || 1);
}
canvas.setDirty(true, true);
}
}
app.registerExtension({
name: "rgthree.Bookmark",
registerCustomNodes() {
Bookmark.setUp();
},
});
+52
View File
@@ -0,0 +1,52 @@
import type {LGraphNode} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {BaseNodeModeChanger} from "./base_node_mode_changer.js";
import {NodeTypesString} from "./constants.js";
const MODE_BYPASS = 4;
const MODE_ALWAYS = 0;
class BypasserNode extends BaseNodeModeChanger {
static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
static override type = NodeTypesString.FAST_BYPASSER;
static override title = NodeTypesString.FAST_BYPASSER;
override comfyClass = NodeTypesString.FAST_BYPASSER;
override readonly modeOn = MODE_ALWAYS;
override readonly modeOff = MODE_BYPASS;
constructor(title = BypasserNode.title) {
super(title);
this.onConstructed();
}
override async handleAction(action: string) {
if (action === "Bypass all") {
for (const widget of this.widgets || []) {
this.forceWidgetOff(widget, true);
}
} else if (action === "Enable all") {
for (const widget of this.widgets || []) {
this.forceWidgetOn(widget, true);
}
} else if (action === "Toggle all") {
for (const widget of this.widgets || []) {
this.forceWidgetToggle(widget, true);
}
}
}
}
app.registerExtension({
name: "rgthree.Bypasser",
registerCustomNodes() {
BypasserNode.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == BypasserNode.title) {
(node as any)._tempWidth = node.size[0];
}
},
});
+264
View File
@@ -0,0 +1,264 @@
import {app} from "scripts/app.js";
import {iconGear, iconStarFilled, logoRgthreeAsync} from "rgthree/common/media/svgs.js";
import {$el, empty} from "rgthree/common/utils_dom.js";
import {SERVICE as BOOKMARKS_SERVICE} from "./services/bookmarks_services.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
import {RgthreeConfigDialog} from "./config.js";
import {wait} from "rgthree/common/shared_utils.js";
let rgthreeButtonGroup: RgthreeComfyButtonGroup | null = null;
function addRgthreeTopBarButtons() {
if (!CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.enabled")) {
if (rgthreeButtonGroup?.element?.parentElement) {
rgthreeButtonGroup.element.parentElement.removeChild(rgthreeButtonGroup.element);
}
return;
} else if (rgthreeButtonGroup) {
app.menu?.settingsGroup.element.before(rgthreeButtonGroup.element);
return;
}
const buttons = [];
const rgthreeButton = new RgthreeComfyButton({
icon: "<svg></svg>",
tooltip: "rgthree-comfy",
primary: true,
// content: 'rgthree-comfy',
// app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
});
buttons.push(rgthreeButton);
logoRgthreeAsync().then((t) => {
rgthreeButton.setIcon(t);
});
rgthreeButton.withPopup(
new RgthreeComfyPopup(
{target: rgthreeButton.element},
$el("menu.rgthree-menu.rgthree-top-menu", {
children: [
$el("li", {
child: $el("button.rgthree-button-reset", {
html: iconGear + "Settings (rgthree-comfy)",
onclick: () => new RgthreeConfigDialog().show(),
}),
}),
$el("li", {
child: $el("button.rgthree-button-reset", {
html: iconStarFilled + "Star on Github",
onclick: () => window.open("https://github.com/rgthree/rgthree-comfy", "_blank"),
}),
}),
],
}),
),
"click",
);
if (CONFIG_SERVICE.getFeatureValue("comfy_top_bar_menu.button_bookmarks.enabled")) {
const bookmarksListEl = $el("menu.rgthree-menu.rgthree-top-menu");
bookmarksListEl.appendChild(
$el("li.rgthree-message", {
child: $el("span", {text: "No bookmarks in current workflow."}),
}),
);
const bookmarksButton = new RgthreeComfyButton({
icon: "bookmark",
tooltip: "Workflow Bookmarks (rgthree-comfy)",
// app,
});
const bookmarksPopup = new RgthreeComfyPopup(
{target: bookmarksButton.element, modal: false},
bookmarksListEl,
);
bookmarksPopup.onOpen(() => {
const bookmarks = BOOKMARKS_SERVICE.getCurrentBookmarks();
empty(bookmarksListEl);
if (bookmarks.length) {
for (const b of bookmarks) {
bookmarksListEl.appendChild(
$el("li", {
child: $el("button.rgthree-button-reset", {
text: `[${b.shortcutKey}] ${b.title}`,
onclick: () => {
b.canvasToBookmark();
},
}),
}),
);
}
} else {
bookmarksListEl.appendChild(
$el("li.rgthree-message", {
child: $el("span", {text: "No bookmarks in current workflow."}),
}),
);
}
// bookmarksPopup.update();
});
bookmarksButton.withPopup(bookmarksPopup, "hover");
buttons.push(bookmarksButton);
}
rgthreeButtonGroup = new RgthreeComfyButtonGroup(...buttons);
app.menu?.settingsGroup.element.before(rgthreeButtonGroup.element);
}
app.registerExtension({
name: "rgthree.TopMenu",
async setup() {
addRgthreeTopBarButtons();
CONFIG_SERVICE.addEventListener("config-change", ((e: CustomEvent) => {
if (e.detail?.key?.includes("features.comfy_top_bar_menu")) {
addRgthreeTopBarButtons();
}
}) as EventListener);
},
});
// The following are rough hacks since ComfyUI took away their button/buttongroup/popup
// functionality. TODO: Find a better spot to add rgthree controls to the UI, I suppose.
class RgthreeComfyButtonGroup {
element = $el("div.rgthree-comfybar-top-button-group");
buttons: RgthreeComfyButton[];
constructor(...buttons: RgthreeComfyButton[]) {
this.buttons = buttons;
this.update();
}
insert(button: RgthreeComfyButton, index: number) {
this.buttons.splice(index, 0, button);
this.update();
}
append(button: RgthreeComfyButton) {
this.buttons.push(button);
this.update();
}
remove(indexOrButton: RgthreeComfyButton | number) {
if (typeof indexOrButton !== "number") {
indexOrButton = this.buttons.indexOf(indexOrButton);
}
if (indexOrButton > -1) {
const btn = this.buttons.splice(indexOrButton, 1);
this.update();
return btn;
}
return null;
}
update() {
this.element.replaceChildren(...this.buttons.map((b) => b["element"] ?? b));
}
}
interface RgthreeComfyButtonOptions {
icon?: string;
primary?: boolean;
overIcon?: string;
iconSize?: number;
content?: string | HTMLElement;
tooltip?: string;
enabled?: boolean;
action?: (e: Event, btn: RgthreeComfyButton) => void;
classList?: string;
visibilitySetting?: {id: string; showValue: any};
// app?: ComfyApp;
}
class RgthreeComfyButton {
element = $el("button.rgthree-comfybar-top-button.rgthree-button-reset.rgthree-button");
iconElement = $el("span.rgthree-button-icon");
constructor(opts: RgthreeComfyButtonOptions) {
opts.icon && this.setIcon(opts.icon);
opts.tooltip && this.element.setAttribute("title", opts.tooltip);
opts.primary && this.element.classList.add("-primary");
}
setIcon(iconOrMarkup: string) {
const markup = iconOrMarkup.startsWith("<")
? iconOrMarkup
: `<i class="mdi mdi-${iconOrMarkup}"></i>`;
this.iconElement.innerHTML = markup;
if (!this.iconElement.parentElement) {
this.element.appendChild(this.iconElement);
}
}
withPopup(popup: RgthreeComfyPopup, trigger: "click" | "hover") {
if (trigger === "click") {
this.element.addEventListener("click", () => {
popup.open();
});
}
if (trigger === "hover") {
this.element.addEventListener("pointerenter", () => {
popup.open();
});
}
}
}
interface RgthreeComfyPopupOptions {
target: HTMLElement;
classList?: string;
modal?: boolean;
}
class RgthreeComfyPopup {
element: HTMLElement;
target?: HTMLElement;
onOpenFn: (() => Promise<void> | void) | null = null;
opts: RgthreeComfyPopupOptions;
onWindowClickBound = this.onWindowClick.bind(this);
constructor(opts: RgthreeComfyPopupOptions, element: HTMLElement) {
this.element = element;
this.opts = opts;
opts.target && (this.target = opts.target);
opts.modal && this.element.classList.add("-modal");
}
async open() {
if (!this.target) {
throw new Error("No target for RgthreeComfyPopup");
}
if (this.onOpenFn) {
await this.onOpenFn();
}
await wait(16);
const rect = this.target.getBoundingClientRect();
this.element.setAttribute("state", "measuring");
document.body.appendChild(this.element);
this.element.style.position = "fixed";
this.element.style.left = `${rect.left}px`;
this.element.style.top = `${rect.top + rect.height}px`;
this.element.setAttribute("state", "open");
if (this.opts.modal) {
document.body.classList.add("rgthree-modal-menu-open");
}
window.addEventListener("click", this.onWindowClickBound);
}
close() {
this.element.remove();
document.body.classList.remove("rgthree-modal-menu-open");
window.removeEventListener("click", this.onWindowClickBound);
}
onOpen(fn: (() => void) | null) {
this.onOpenFn = fn;
}
onWindowClick() {
this.close();
}
}
+423
View File
@@ -0,0 +1,423 @@
import {app} from "scripts/app.js";
import { RgthreeDialog, RgthreeDialogOptions } from "rgthree/common/dialog.js";
import { createElement as $el, queryAll as $$ } from "rgthree/common/utils_dom.js";
import { checkmark, logoRgthree } from "rgthree/common/media/svgs.js";
import { LogLevel, rgthree } from "./rgthree.js";
import { SERVICE as CONFIG_SERVICE } from "./services/config_service.js";
/** Types of config used as a hint for the form handling. */
enum ConfigType {
UNKNOWN,
BOOLEAN,
STRING,
NUMBER,
ARRAY,
}
enum ConfigInputType {
UNKNOWN,
CHECKLIST, // Which is a multiselect array.
}
const TYPE_TO_STRING = {
[ConfigType.UNKNOWN]: "unknown",
[ConfigType.BOOLEAN]: "boolean",
[ConfigType.STRING]: "string",
[ConfigType.NUMBER]: "number",
[ConfigType.ARRAY]: "array",
};
type ConfigurationSchema = {
key: string;
type: ConfigType;
label: string;
inputType?: ConfigInputType,
options?: string[] | number[] | ConfigurationSchemaOption[];
description?: string;
subconfig?: ConfigurationSchema[];
isDevOnly?: boolean;
onSave?: (value: any) => void;
};
type ConfigurationSchemaOption = { value: any; label: string };
/**
* A static schema of sorts to layout options found in the config.
*/
const CONFIGURABLE: { [key: string]: ConfigurationSchema[] } = {
features: [
{
key: "features.progress_bar.enabled",
type: ConfigType.BOOLEAN,
label: "Prompt Progress Bar",
description: `Shows a minimal progress bar for nodes and steps at the top of the app.`,
subconfig: [
{
key: "features.progress_bar.height",
type: ConfigType.NUMBER,
label: "Height of the bar",
},
{
key: "features.progress_bar.position",
type: ConfigType.STRING,
label: "Position at top or bottom of window",
options: ["top", "bottom"],
},
],
},
{
key: "features.import_individual_nodes.enabled",
type: ConfigType.BOOLEAN,
label: "Import Individual Nodes Widgets",
description:
"Dragging & Dropping a similar image/JSON workflow onto (most) current workflow nodes" +
"will allow you to import that workflow's node's widgets when it has the same " +
"id and type. This is useful when you have several images and you'd like to import just " +
"one part of a previous iteration, like a seed, or prompt.",
},
],
menus: [
{
key: "features.comfy_top_bar_menu.enabled",
type: ConfigType.BOOLEAN,
label: "Enable Top Bar Menu",
description:
"Have quick access from ComfyUI's new top bar to rgthree-comfy bookmarks, settings " +
"(and more to come).",
},
{
key: "features.menu_queue_selected_nodes",
type: ConfigType.BOOLEAN,
label: "Show 'Queue Selected Output Nodes'",
description:
"Will show a menu item in the right-click context menus to queue (only) the selected " +
"output nodes.",
},
{
key: "features.menu_auto_nest.subdirs",
type: ConfigType.BOOLEAN,
label: "Auto Nest Subdirectories in Menus",
description:
"When a large, flat list of values contain sub-directories, auto nest them. (Like, for " +
"a large list of checkpoints).",
subconfig: [
{
key: "features.menu_auto_nest.threshold",
type: ConfigType.NUMBER,
label: "Number of items needed to trigger nesting.",
},
],
},
{
key: "features.menu_bookmarks.enabled",
type: ConfigType.BOOLEAN,
label: "Show Bookmarks in context menu",
description: "Will list bookmarks in the rgthree-comfy right-click context menu.",
},
],
groups: [
{
key: "features.group_header_fast_toggle.enabled",
type: ConfigType.BOOLEAN,
label: "Show fast toggles in Group Headers",
description: "Show quick toggles in Groups' Headers to quickly mute, bypass or queue.",
subconfig: [
{
key: "features.group_header_fast_toggle.toggles",
type: ConfigType.ARRAY,
label: "Which toggles to show.",
inputType: ConfigInputType.CHECKLIST,
options: [
{ value: "queue", label: "queue" },
{ value: "bypass", label: "bypass" },
{ value: "mute", label: "mute" },
],
},
{
key: "features.group_header_fast_toggle.show",
type: ConfigType.STRING,
label: "When to show them.",
options: [
{ value: "hover", label: "on hover" },
{ value: "always", label: "always" },
],
},
],
},
],
power_lora_loader: [
{
key: "nodes.power_lora_loader.show_info_badge",
type: ConfigType.BOOLEAN,
label: "Show info badge/button",
description: "Show an info badge/button on each lora row to signal and open lora details.",
},
{
key: "nodes.power_lora_loader.info_autoplay_video",
type: ConfigType.BOOLEAN,
label: "Autoplay video previews in info dialog.",
description: "In the info dialog, autoplay video preview images.",
},
],
advanced: [
{
key: "features.show_alerts_for_corrupt_workflows",
type: ConfigType.BOOLEAN,
label: "Detect Corrupt Workflows",
description:
"Will show a message at the top of the screen when loading a workflow that has " +
"corrupt linking data.",
},
{
key: "log_level",
type: ConfigType.STRING,
label: "Log level for browser dev console.",
description:
"Further down the list, the more verbose logs to the console will be. For instance, " +
"selecting 'IMPORTANT' means only important message will be logged to the browser " +
"console, while selecting 'WARN' will log all messages at or higher than WARN, including " +
"'ERROR' and 'IMPORTANT' etc.",
options: ["IMPORTANT", "ERROR", "WARN", "INFO", "DEBUG", "DEV"],
isDevOnly: true,
onSave: function (value: LogLevel) {
rgthree.setLogLevel(value);
},
},
{
key: "features.invoke_extensions_async.node_created",
type: ConfigType.BOOLEAN,
label: "Allow other extensions to call nodeCreated on rgthree-nodes.",
isDevOnly: true,
description:
"Do not disable unless you are having trouble (and then file an issue at rgthree-comfy)." +
"Prior to Apr 2024 it was not possible for other extensions to invoke their nodeCreated " +
"event on some rgthree-comfy nodes. Now it's possible and this option is only here in " +
"for easy if something is wrong.",
},
],
};
/**
* Creates a new fieldrow for main or sub configuration items.
*/
function fieldrow(item: ConfigurationSchema) {
const initialValue = CONFIG_SERVICE.getConfigValue(item.key);
const container = $el(`div.fieldrow.-type-${TYPE_TO_STRING[item.type]}`, {
dataset: {
name: item.key,
initial: initialValue,
type: item.type,
},
});
$el(`label[for="${item.key}"]`, {
children: [
$el(`span[text="${item.label}"]`),
item.description ? $el("small", { html: item.description }) : null,
],
parent: container,
});
let input;
if (item.options?.length) {
if (item.inputType === ConfigInputType.CHECKLIST) {
const initialValueList = initialValue || [];
input = $el<HTMLSelectElement>(`fieldset.rgthree-checklist-group[id="${item.key}"]`, {
parent: container,
children: item.options.map((o) => {
const label = (o as ConfigurationSchemaOption).label || String(o);
const value = (o as ConfigurationSchemaOption).value || o;
const id = `${item.key}_${value}`;
return $el<HTMLSpanElement>(`span.rgthree-checklist-item`, {
children: [
$el<HTMLInputElement>(`input[type="checkbox"][value="${value}"]`, {
id,
checked: initialValueList.includes(value),
}),
$el<HTMLInputElement>(`label`, {
for: id,
text: label,
})
]
});
}),
});
} else {
input = $el<HTMLSelectElement>(`select[id="${item.key}"]`, {
parent: container,
children: item.options.map((o) => {
const label = (o as ConfigurationSchemaOption).label || String(o);
const value = (o as ConfigurationSchemaOption).value || o;
const valueSerialized = JSON.stringify({ value: value });
return $el<HTMLOptionElement>(`option[value="${valueSerialized}"]`, {
text: label,
selected: valueSerialized === JSON.stringify({ value: initialValue }),
});
}),
});
}
} else if (item.type === ConfigType.BOOLEAN) {
container.classList.toggle("-checked", !!initialValue);
input = $el<HTMLInputElement>(`input[type="checkbox"][id="${item.key}"]`, {
parent: container,
checked: initialValue,
});
} else {
input = $el(`input[id="${item.key}"]`, {
parent: container,
value: initialValue,
});
}
$el("div.fieldrow-value", { children: [input], parent: container });
return container;
}
/**
* A dialog to edit rgthree-comfy settings and config.
*/
export class RgthreeConfigDialog extends RgthreeDialog {
constructor() {
const content = $el("div");
content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["features"]!, "Features"));
content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["menus"]!, "Menus"));
content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["groups"]!, "Groups"));
content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["power_lora_loader"]!, "Power Lora Loader"));
content.appendChild(RgthreeConfigDialog.buildFieldset(CONFIGURABLE["advanced"]!, "Advanced"));
content.addEventListener("input", (e) => {
const changed = this.getChangedFormData();
($$(".save-button", this.element)[0] as HTMLButtonElement).disabled =
!Object.keys(changed).length;
});
content.addEventListener("change", (e) => {
const changed = this.getChangedFormData();
($$(".save-button", this.element)[0] as HTMLButtonElement).disabled =
!Object.keys(changed).length;
});
const dialogOptions: RgthreeDialogOptions = {
class: "-iconed -settings",
title: logoRgthree + `<h2>Settings - rgthree-comfy</h2>`,
content,
onBeforeClose: () => {
const changed = this.getChangedFormData();
if (Object.keys(changed).length) {
return confirm("Looks like there are unsaved changes. Are you sure you want close?");
}
return true;
},
buttons: [
{
label: "Save",
disabled: true,
className: "rgthree-button save-button -blue",
callback: async (e) => {
const changed = this.getChangedFormData();
if (!Object.keys(changed).length) {
this.close();
return;
}
const success = await CONFIG_SERVICE.setConfigValues(changed);
if (success) {
for (const key of Object.keys(changed)) {
Object.values(CONFIGURABLE)
.flat()
.find((f) => f.key === key)
?.onSave?.(changed[key]);
}
this.close();
rgthree.showMessage({
id: "config-success",
message: `${checkmark} Successfully saved rgthree-comfy settings!`,
timeout: 4000,
});
($$(".save-button", this.element)[0] as HTMLButtonElement).disabled = true;
} else {
alert("There was an error saving rgthree-comfy configuration.");
}
},
},
],
};
super(dialogOptions);
}
private static buildFieldset(datas: ConfigurationSchema[], label: string) {
const fieldset = $el(`fieldset`, { children: [$el(`legend[text="${label}"]`)] });
for (const data of datas) {
if (data.isDevOnly && !rgthree.isDevMode()) {
continue;
}
const container = $el("div.formrow");
container.appendChild(fieldrow(data));
if (data.subconfig) {
for (const subfeature of data.subconfig) {
container.appendChild(fieldrow(subfeature));
}
}
fieldset.appendChild(container);
}
return fieldset;
}
getChangedFormData() {
return $$("[data-name]", this.contentElement).reduce((acc: { [key: string]: any }, el) => {
const name = el.dataset["name"]!;
const type = el.dataset["type"]!;
const initialValue = CONFIG_SERVICE.getConfigValue(name);
let currentValueEl = $$("fieldset.rgthree-checklist-group, input, textarea, select", el)[0] as HTMLInputElement;
let currentValue: any = null;
if (type === String(ConfigType.BOOLEAN)) {
currentValue = currentValueEl.checked;
// Not sure I like this side effect in here, but it's easy to just do it now.
el.classList.toggle("-checked", currentValue);
} else {
currentValue = currentValueEl?.value;
if (currentValueEl.nodeName === "SELECT") {
currentValue = JSON.parse(currentValue).value;
} else if (currentValueEl.classList.contains('rgthree-checklist-group')) {
currentValue = [];
for (const check of $$<HTMLInputElement>('input[type="checkbox"]', currentValueEl)) {
if (check.checked) {
currentValue.push(check.value);
}
}
} else if (type === String(ConfigType.NUMBER)) {
currentValue = Number(currentValue) || initialValue;
}
}
if (JSON.stringify(currentValue) !== JSON.stringify(initialValue)) {
acc[name] = currentValue;
}
return acc;
}, {});
}
}
app.registerExtension({
name: 'rgthree.Config',
settings: [{
id: "rgthree.config" as any,
defaultValue: null,
name: "Open rgthree-comfy config",
type: () => {
// Adds a row to open the dialog from the ComfyUI settings.
return $el("div.rgthree-comfyui-settings-row", {
children: [
$el("div", {
child: $el('button.rgthree-button.-blue', {
html: `${logoRgthree} rgthree-comfy settings`,
events: {
click: (e: PointerEvent) => {
new RgthreeConfigDialog().show();
},
},
}),
}),
],
});
},
}]
});
+72
View File
@@ -0,0 +1,72 @@
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
export function addRgthree(str: string) {
return str + " (rgthree)";
}
export function stripRgthree(str: string) {
return str.replace(/\s*\(rgthree\)$/, "");
}
export const NodeTypesString = {
ANY_SWITCH: addRgthree("Any Switch"),
CONTEXT: addRgthree("Context"),
CONTEXT_BIG: addRgthree("Context Big"),
CONTEXT_SWITCH: addRgthree("Context Switch"),
CONTEXT_SWITCH_BIG: addRgthree("Context Switch Big"),
CONTEXT_MERGE: addRgthree("Context Merge"),
CONTEXT_MERGE_BIG: addRgthree("Context Merge Big"),
DYNAMIC_CONTEXT: addRgthree("Dynamic Context"),
DYNAMIC_CONTEXT_SWITCH: addRgthree("Dynamic Context Switch"),
DISPLAY_ANY: addRgthree("Display Any"),
IMAGE_OR_LATENT_SIZE: addRgthree("Image or Latent Size"),
NODE_MODE_RELAY: addRgthree("Mute / Bypass Relay"),
NODE_MODE_REPEATER: addRgthree("Mute / Bypass Repeater"),
FAST_MUTER: addRgthree("Fast Muter"),
FAST_BYPASSER: addRgthree("Fast Bypasser"),
FAST_GROUPS_MUTER: addRgthree("Fast Groups Muter"),
FAST_GROUPS_BYPASSER: addRgthree("Fast Groups Bypasser"),
FAST_ACTIONS_BUTTON: addRgthree("Fast Actions Button"),
LABEL: addRgthree("Label"),
POWER_PRIMITIVE: addRgthree("Power Primitive"),
POWER_PROMPT: addRgthree("Power Prompt"),
POWER_PROMPT_SIMPLE: addRgthree("Power Prompt - Simple"),
POWER_PUTER: addRgthree("Power Puter"),
POWER_CONDUCTOR: addRgthree("Power Conductor"),
SDXL_EMPTY_LATENT_IMAGE: addRgthree("SDXL Empty Latent Image"),
SDXL_POWER_PROMPT_POSITIVE: addRgthree("SDXL Power Prompt - Positive"),
SDXL_POWER_PROMPT_NEGATIVE: addRgthree("SDXL Power Prompt - Simple / Negative"),
POWER_LORA_LOADER: addRgthree("Power Lora Loader"),
KSAMPLER_CONFIG: addRgthree("KSampler Config"),
NODE_COLLECTOR: addRgthree("Node Collector"),
REROUTE: addRgthree("Reroute"),
RANDOM_UNMUTER: addRgthree("Random Unmuter"),
SEED: addRgthree("Seed"),
BOOKMARK: addRgthree("Bookmark"),
IMAGE_COMPARER: addRgthree("Image Comparer"),
IMAGE_INSET_CROP: addRgthree("Image Inset Crop"),
};
const UNRELEASED_KEYS = {
[NodeTypesString.DYNAMIC_CONTEXT]: "dynamic_context",
[NodeTypesString.DYNAMIC_CONTEXT_SWITCH]: "dynamic_context",
[NodeTypesString.POWER_CONDUCTOR]: "power_conductor",
};
/**
* Gets the list of nodes from NoteTypeString above, filtering any that are not applicable.
*/
export function getNodeTypeStrings() {
const unreleasedKeys = Object.keys(UNRELEASED_KEYS);
return Object.values(NodeTypesString)
.map((i) => stripRgthree(i))
.filter((i) => {
if (unreleasedKeys.includes(i)) {
return !!CONFIG_SERVICE.getConfigValue(`unreleased.${UNRELEASED_KEYS[i]}.enabled`)
}
return true;
})
.sort();
}
+488
View File
@@ -0,0 +1,488 @@
import type {
ComfyNodeDef,
INodeInputSlot,
INodeOutputSlot,
LGraphCanvas as TLGraphCanvas,
LGraphNode as TLGraphNode,
LLink,
ISlotType,
ConnectByTypeOptions,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {
IoDirection,
addConnectionLayoutSupport,
addMenuItem,
matchLocalSlotsToServer,
replaceNode,
} from "./utils.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
import {RgthreeBaseServerNodeConstructor} from "typings/rgthree.js";
import {debounce, wait} from "rgthree/common/shared_utils.js";
import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
import {NodeTypesString} from "./constants.js";
/**
* Takes a non-context node and determins for its input or output slot, if there is a valid
* connection for an opposite context output or input slot.
*/
function findMatchingIndexByTypeOrName(
otherNode: TLGraphNode,
otherSlot: INodeInputSlot | INodeOutputSlot,
ctxSlots: INodeInputSlot[] | INodeOutputSlot[],
) {
const otherNodeType = (otherNode.type || "").toUpperCase();
const otherNodeName = (otherNode.title || "").toUpperCase();
let otherSlotType = otherSlot.type as string;
if (Array.isArray(otherSlotType) || otherSlotType.includes(",")) {
otherSlotType = "COMBO";
}
const otherSlotName = otherSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
let ctxSlotIndex = -1;
if (["CONDITIONING", "INT", "STRING", "FLOAT", "COMBO"].includes(otherSlotType)) {
ctxSlotIndex = ctxSlots.findIndex((ctxSlot) => {
const ctxSlotName = ctxSlot.name.toUpperCase().replace("OPT_", "").replace("_NAME", "");
let ctxSlotType = ctxSlot.type as string;
if (Array.isArray(ctxSlotType) || ctxSlotType.includes(",")) {
ctxSlotType = "COMBO";
}
if (ctxSlotType !== otherSlotType) {
return false;
}
// Straightforward matches.
if (
ctxSlotName === otherSlotName ||
(ctxSlotName === "SEED" && otherSlotName.includes("SEED")) ||
(ctxSlotName === "STEP_REFINER" && otherSlotName.includes("AT_STEP")) ||
(ctxSlotName === "STEP_REFINER" && otherSlotName.includes("REFINER_STEP"))
) {
return true;
}
// If postive other node, try to match conditining and text.
if (
(otherNodeType.includes("POSITIVE") || otherNodeName.includes("POSITIVE")) &&
((ctxSlotName === "POSITIVE" && otherSlotType === "CONDITIONING") ||
(ctxSlotName === "TEXT_POS_G" && otherSlotName.includes("TEXT_G")) ||
(ctxSlotName === "TEXT_POS_L" && otherSlotName.includes("TEXT_L")))
) {
return true;
}
if (
(otherNodeType.includes("NEGATIVE") || otherNodeName.includes("NEGATIVE")) &&
((ctxSlotName === "NEGATIVE" && otherSlotType === "CONDITIONING") ||
(ctxSlotName === "TEXT_NEG_G" && otherSlotName.includes("TEXT_G")) ||
(ctxSlotName === "TEXT_NEG_L" && otherSlotName.includes("TEXT_L")))
) {
return true;
}
return false;
});
} else {
ctxSlotIndex = ctxSlots.map((s) => s.type).indexOf(otherSlotType);
}
return ctxSlotIndex;
}
/**
* A Base Context node for other context based nodes to extend.
*/
export class BaseContextNode extends RgthreeBaseServerNode {
constructor(title: string) {
super(title);
}
// LiteGraph adds more spacing than we want when calculating a nodes' `_collapsed_width`, so we'll
// override it with a setter and re-set it measured exactly as we want.
___collapsed_width: number = 0;
//@ts-ignore - TS Doesn't like us overriding a property with accessors but, too bad.
override get _collapsed_width() {
return this.___collapsed_width;
}
override set _collapsed_width(width: number) {
const canvas = app.canvas as TLGraphCanvas;
const ctx = canvas.canvas.getContext("2d")!;
const oldFont = ctx.font;
ctx.font = canvas.title_text_font;
let title = this.title.trim();
this.___collapsed_width = 30 + (title ? 10 + ctx.measureText(title).width : 0);
ctx.font = oldFont;
}
override connectByType(
slot: number | string,
targetNode: TLGraphNode,
targetSlotType: ISlotType,
optsIn?: ConnectByTypeOptions,
): LLink | null {
let canConnect = super.connectByType?.call(this, slot, targetNode, targetSlotType, optsIn);
if (!super.connectByType) {
canConnect = LGraphNode.prototype.connectByType.call(
this,
slot,
targetNode,
targetSlotType,
optsIn,
);
}
if (!canConnect && slot === 0) {
const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
// Okay, we've dragged a context and it can't connect.. let's connect all the other nodes.
// Unfortunately, we don't know which are null now, so we'll just connect any that are
// not already connected.
for (const [index, input] of (targetNode.inputs || []).entries()) {
if (input.link && !ctrlKey) {
continue;
}
const thisOutputSlot = findMatchingIndexByTypeOrName(targetNode, input, this.outputs);
if (thisOutputSlot > -1) {
this.connect(thisOutputSlot, targetNode, index);
}
}
}
return null;
}
override connectByTypeOutput(
slot: number | string,
sourceNode: TLGraphNode,
sourceSlotType: ISlotType,
optsIn?: ConnectByTypeOptions,
): LLink | null {
let canConnect = super.connectByTypeOutput?.call(
this,
slot,
sourceNode,
sourceSlotType,
optsIn,
);
if (!super.connectByType) {
canConnect = LGraphNode.prototype.connectByTypeOutput.call(
this,
slot,
sourceNode,
sourceSlotType,
optsIn,
);
}
if (!canConnect && slot === 0) {
const ctrlKey = KEY_EVENT_SERVICE.ctrlKey;
// Okay, we've dragged a context and it can't connect.. let's connect all the other nodes.
// Unfortunately, we don't know which are null now, so we'll just connect any that are
// not already connected.
for (const [index, output] of (sourceNode.outputs || []).entries()) {
if (output.links?.length && !ctrlKey) {
continue;
}
const thisInputSlot = findMatchingIndexByTypeOrName(sourceNode, output, this.inputs);
if (thisInputSlot > -1) {
sourceNode.connect(index, this, thisInputSlot);
}
}
}
return null;
}
static override setUp(
comfyClass: typeof LGraphNode,
nodeData: ComfyNodeDef,
ctxClass: RgthreeBaseServerNodeConstructor,
) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, ctxClass);
// [🤮] ComfyUI only adds "required" inputs to the outputs list when dragging an output to
// empty space, but since RGTHREE_CONTEXT is optional, it doesn't get added to the menu because
// ...of course. So, we'll manually add it. Of course, we also have to do this in a timeout
// because ComfyUI clears out `LiteGraph.slot_types_default_out` in its own 'Comfy.SlotDefaults'
// extension and we need to wait for that to happen.
wait(500).then(() => {
LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] =
LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"] || [];
LiteGraph.slot_types_default_out["RGTHREE_CONTEXT"].push((comfyClass as any).comfyClass);
});
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
addConnectionLayoutSupport(ctxClass, app, [
["Left", "Right"],
["Right", "Left"],
]);
setTimeout(() => {
ctxClass.category = comfyClass.category;
});
}
}
/**
* The original Context node.
*/
class ContextNode extends BaseContextNode {
static override title = NodeTypesString.CONTEXT;
static override type = NodeTypesString.CONTEXT;
static comfyClass = NodeTypesString.CONTEXT;
constructor(title = ContextNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextNode, app, {
name: "Convert To Context Big",
callback: (node) => {
replaceNode(node, ContextBigNode.type);
},
});
}
}
/**
* The Context Big node.
*/
class ContextBigNode extends BaseContextNode {
static override title = NodeTypesString.CONTEXT_BIG;
static override type = NodeTypesString.CONTEXT_BIG;
static comfyClass = NodeTypesString.CONTEXT_BIG;
constructor(title = ContextBigNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextBigNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextBigNode, app, {
name: "Convert To Context (Original)",
callback: (node) => {
replaceNode(node, ContextNode.type);
},
});
}
}
/**
* A base node for Context Switche nodes and Context Merges nodes that will always add another empty
* ctx input, no less than five.
*/
class BaseContextMultiCtxInputNode extends BaseContextNode {
private stabilizeBound = this.stabilize.bind(this);
constructor(title: string) {
super(title);
// Adding five. Note, configure will add as many as was in the stored workflow automatically.
this.addContextInput(5);
}
private addContextInput(num = 1) {
for (let i = 0; i < num; i++) {
this.addInput(`ctx_${String(this.inputs.length + 1).padStart(2, "0")}`, "RGTHREE_CONTEXT");
}
}
override onConnectionsChange(
type: number,
slotIndex: number,
isConnected: boolean,
link: LLink,
ioSlot: INodeInputSlot | INodeOutputSlot,
): void {
super.onConnectionsChange?.apply(this, [...arguments] as any);
if (type === LiteGraph.INPUT) {
this.scheduleStabilize();
}
}
private scheduleStabilize(ms = 64) {
return debounce(this.stabilizeBound, 64);
}
/**
* Stabilizes the inputs; removing any disconnected ones from the bottom, then adding an empty
* one to the end so we always have one empty one to expand.
*/
private stabilize() {
removeUnusedInputsFromEnd(this, 4);
this.addContextInput();
}
}
/**
* The Context Switch (original) node.
*/
class ContextSwitchNode extends BaseContextMultiCtxInputNode {
static override title = NodeTypesString.CONTEXT_SWITCH;
static override type = NodeTypesString.CONTEXT_SWITCH;
static comfyClass = NodeTypesString.CONTEXT_SWITCH;
constructor(title = ContextSwitchNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextSwitchNode, app, {
name: "Convert To Context Switch Big",
callback: (node) => {
replaceNode(node, ContextSwitchBigNode.type);
},
});
}
}
/**
* The Context Switch Big node.
*/
class ContextSwitchBigNode extends BaseContextMultiCtxInputNode {
static override title = NodeTypesString.CONTEXT_SWITCH_BIG;
static override type = NodeTypesString.CONTEXT_SWITCH_BIG;
static comfyClass = NodeTypesString.CONTEXT_SWITCH_BIG;
constructor(title = ContextSwitchBigNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextSwitchBigNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextSwitchBigNode, app, {
name: "Convert To Context Switch",
callback: (node) => {
replaceNode(node, ContextSwitchNode.type);
},
});
}
}
/**
* The Context Merge (original) node.
*/
class ContextMergeNode extends BaseContextMultiCtxInputNode {
static override title = NodeTypesString.CONTEXT_MERGE;
static override type = NodeTypesString.CONTEXT_MERGE;
static comfyClass = NodeTypesString.CONTEXT_MERGE;
constructor(title = ContextMergeNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextMergeNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextMergeNode, app, {
name: "Convert To Context Merge Big",
callback: (node) => {
replaceNode(node, ContextMergeBigNode.type);
},
});
}
}
/**
* The Context Switch Big node.
*/
class ContextMergeBigNode extends BaseContextMultiCtxInputNode {
static override title = NodeTypesString.CONTEXT_MERGE_BIG;
static override type = NodeTypesString.CONTEXT_MERGE_BIG;
static comfyClass = NodeTypesString.CONTEXT_MERGE_BIG;
constructor(title = ContextMergeBigNode.title) {
super(title);
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
BaseContextNode.setUp(comfyClass, nodeData, ContextMergeBigNode);
}
static override onRegisteredForOverride(comfyClass: any, ctxClass: any) {
BaseContextNode.onRegisteredForOverride(comfyClass, ctxClass);
addMenuItem(ContextMergeBigNode, app, {
name: "Convert To Context Switch",
callback: (node) => {
replaceNode(node, ContextMergeNode.type);
},
});
}
}
const contextNodes = [
ContextNode,
ContextBigNode,
ContextSwitchNode,
ContextSwitchBigNode,
ContextMergeNode,
ContextMergeBigNode,
];
const contextTypeToServerDef: {[type: string]: ComfyNodeDef} = {};
function fixBadConfigs(node: ContextNode) {
// Dumb mistake, but let's fix our mispelling. This will probably need to stay in perpetuity to
// keep any old workflows operating.
const wrongName = node.outputs.find((o, i) => o.name === "CLIP_HEIGTH");
if (wrongName) {
wrongName.name = "CLIP_HEIGHT";
}
}
app.registerExtension({
name: "rgthree.Context",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
// Loop over out context nodes and see if any match the server data.
for (const ctxClass of contextNodes) {
if (nodeData.name === ctxClass.type) {
contextTypeToServerDef[ctxClass.type] = nodeData;
ctxClass.setUp(nodeType, nodeData);
break;
}
}
},
async nodeCreated(node: TLGraphNode) {
const type = node.type || (node.constructor as any).type;
const serverDef = type && contextTypeToServerDef[type];
if (serverDef) {
fixBadConfigs(node as ContextNode);
matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
// Switches don't need to change inputs, only context outputs
if (!type!.includes("Switch") && !type!.includes("Merge")) {
matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
}
// }, 100);
}
},
/**
* When we're loaded from the server, check if we're using an out of date version and update our
* inputs / outputs to match.
*/
async loadedGraphNode(node: TLGraphNode) {
const type = node.type || (node.constructor as any).type;
const serverDef = type && contextTypeToServerDef[type];
if (serverDef) {
fixBadConfigs(node as ContextNode);
matchLocalSlotsToServer(node, IoDirection.OUTPUT, serverDef);
// Switches don't need to change inputs, only context outputs
if (!type!.includes("Switch") && !type!.includes("Merge")) {
matchLocalSlotsToServer(node, IoDirection.INPUT, serverDef);
}
}
},
});
+484
View File
@@ -0,0 +1,484 @@
import {RgthreeDialog, RgthreeDialogOptions} from "rgthree/common/dialog.js";
import {
createElement as $el,
empty,
appendChildren,
getClosestOrSelf,
query,
queryAll,
setAttributes,
} from "rgthree/common/utils_dom.js";
import {
logoCivitai,
link,
pencilColored,
diskColored,
dotdotdot,
play,
pause,
volumeHigh,
volumeMute,
} from "rgthree/common/media/svgs.js";
import {RgthreeModelInfo} from "typings/rgthree.js";
import {CHECKPOINT_INFO_SERVICE, LORA_INFO_SERVICE} from "rgthree/common/model_info_service.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
import {rgthree} from "./rgthree.js";
import {MenuButton} from "rgthree/common/menu.js";
import {generateId, injectCss} from "rgthree/common/shared_utils.js";
import {rgthreeApi} from "rgthree/common/rgthree_api.js";
/**
* A dialog that displays information about a model/lora/etc.
*/
abstract class RgthreeInfoDialog extends RgthreeDialog {
private modifiedModelData = false;
private modelInfo: RgthreeModelInfo | null = null;
constructor(file: string) {
const dialogOptions: RgthreeDialogOptions = {
class: "rgthree-info-dialog",
title: `<h2>Loading...</h2>`,
content: "<center>Loading..</center>",
onBeforeClose: () => {
return true;
},
};
super(dialogOptions);
this.init(file);
}
abstract getModelInfo(file: string): Promise<RgthreeModelInfo | null>;
abstract refreshModelInfo(file: string): Promise<RgthreeModelInfo | null>;
abstract clearModelInfo(file: string): Promise<RgthreeModelInfo | null>;
private async init(file: string) {
const cssPromise = injectCss("rgthree/common/css/dialog_model_info.css");
this.modelInfo = await this.getModelInfo(file);
await cssPromise;
this.setContent(this.getInfoContent());
this.setTitle(this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown");
this.attachEvents();
}
protected override getCloseEventDetail(): {detail: any} {
const detail = {
dirty: this.modifiedModelData,
};
return {detail};
}
private attachEvents() {
this.contentElement.addEventListener("click", async (e: MouseEvent) => {
const target = getClosestOrSelf(e.target as HTMLElement, "[data-action]");
const action = target?.getAttribute("data-action");
if (!target || !action) {
return;
}
await this.handleEventAction(action, target, e);
});
}
private async handleEventAction(action: string, target: HTMLElement, e?: Event) {
const info = this.modelInfo!;
if (!info?.file) {
return;
}
if (action === "fetch-civitai") {
this.modelInfo = await this.refreshModelInfo(info.file);
this.setContent(this.getInfoContent());
this.setTitle(this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown");
} else if (action === "copy-trained-words") {
const selected = queryAll(".-rgthree-is-selected", target.closest("tr")!);
const text = selected.map((el) => el.getAttribute("data-word")).join(", ");
await navigator.clipboard.writeText(text);
rgthree.showMessage({
id: "copy-trained-words-" + generateId(4),
type: "success",
message: `Successfully copied ${selected.length} key word${
selected.length === 1 ? "" : "s"
}.`,
timeout: 4000,
});
} else if (action === "toggle-trained-word") {
target?.classList.toggle("-rgthree-is-selected");
const tr = target.closest("tr");
if (tr) {
const span = query("td:first-child > *", tr)!;
let small = query("small", span);
if (!small) {
small = $el("small", {parent: span});
}
const num = queryAll(".-rgthree-is-selected", tr).length;
small.innerHTML = num
? `${num} selected | <span role="button" data-action="copy-trained-words">Copy</span>`
: "";
// this.handleEventAction('copy-trained-words', target, e);
}
} else if (action === "edit-row") {
const tr = target!.closest("tr")!;
const td = query("td:nth-child(2)", tr)!;
const input = td.querySelector("input,textarea");
if (!input) {
const fieldName = tr.dataset["fieldName"] as string;
tr.classList.add("-rgthree-editing");
const isTextarea = fieldName === "userNote";
const input = $el(`${isTextarea ? "textarea" : 'input[type="text"]'}`, {
value: td.textContent,
});
input.addEventListener("keydown", (e) => {
if (!isTextarea && e.key === "Enter") {
const modified = saveEditableRow(info!, tr, true);
this.modifiedModelData = this.modifiedModelData || modified;
e.stopPropagation();
e.preventDefault();
} else if (e.key === "Escape") {
const modified = saveEditableRow(info!, tr, false);
this.modifiedModelData = this.modifiedModelData || modified;
e.stopPropagation();
e.preventDefault();
}
});
appendChildren(empty(td), [input]);
input.focus();
} else if (target!.nodeName.toLowerCase() === "button") {
const modified = saveEditableRow(info!, tr, true);
this.modifiedModelData = this.modifiedModelData || modified;
}
e?.preventDefault();
e?.stopPropagation();
}
}
private getInfoContent() {
const info = this.modelInfo || {};
const civitaiLink = info.links?.find((i) => i.includes("civitai.com/models"));
const html = `
<ul class="rgthree-info-area">
<li title="Type" class="rgthree-info-tag -type -type-${(
info.type || ""
).toLowerCase()}"><span>${info.type || ""}</span></li>
<li title="Base Model" class="rgthree-info-tag -basemodel -basemodel-${(
info.baseModel || ""
).toLowerCase()}"><span>${info.baseModel || ""}</span></li>
<li class="rgthree-info-menu" stub="menu"></li>
${
""
// !civitaiLink
// ? ""
// : `
// <li title="Visit on Civitai" class="-link -civitai"><a href="${civitaiLink}" target="_blank">Civitai ${link}</a></li>
// `
}
</ul>
<table class="rgthree-info-table">
${infoTableRow("File", info.file || "")}
${infoTableRow("Hash (sha256)", info.sha256 || "")}
${
civitaiLink
? infoTableRow(
"Civitai",
`<a href="${civitaiLink}" target="_blank">${logoCivitai}View on Civitai</a>`,
)
: info.raw?.civitai?.error === "Model not found"
? infoTableRow(
"Civitai",
'<i>Model not found</i> <span class="-help" title="The model was not found on civitai with the sha256 hash. It\'s possible the model was removed, re-uploaded, or was never on civitai to begin with."></span>',
)
: info.raw?.civitai?.error
? infoTableRow("Civitai", info.raw?.civitai?.error)
: !info.raw?.civitai
? infoTableRow(
"Civitai",
`<button class="rgthree-button" data-action="fetch-civitai">Fetch info from civitai</button>`,
)
: ""
}
${infoTableRow(
"Name",
info.name || info.raw?.metadata?.ss_output_name || "",
"The name for display.",
"name",
)}
${
!info.baseModelFile && !info.baseModelFile
? ""
: infoTableRow(
"Base Model",
(info.baseModel || "") + (info.baseModelFile ? ` (${info.baseModelFile})` : ""),
)
}
${
!info.trainedWords?.length
? ""
: infoTableRow(
"Trained Words",
getTrainedWordsMarkup(info.trainedWords) ?? "",
"Trained words from the metadata and/or civitai. Click to select for copy.",
)
}
${
!info.raw?.metadata?.ss_clip_skip || info.raw?.metadata?.ss_clip_skip == "None"
? ""
: infoTableRow("Clip Skip", info.raw?.metadata?.ss_clip_skip)
}
${infoTableRow(
"Strength Min",
info.strengthMin ?? "",
"The recommended minimum strength, In the Power Lora Loader node, strength will signal when it is below this threshold.",
"strengthMin",
)}
${infoTableRow(
"Strength Max",
info.strengthMax ?? "",
"The recommended maximum strength. In the Power Lora Loader node, strength will signal when it is above this threshold.",
"strengthMax",
)}
${
"" /*infoTableRow(
"User Tags",
info.userTags?.join(", ") ?? "",
"A list of tags to make filtering easier in the Power Lora Chooser.",
"userTags",
)*/
}
${infoTableRow(
"Additional Notes",
info.userNote ?? "",
"Additional notes you'd like to keep and reference in the info dialog.",
"userNote",
)}
</table>
<ul class="rgthree-info-images">${
info.images
?.map(
(img) => `
<li>
<figure>${
img.type === "video"
? `<video src="${img.url}"
${(CONFIG_SERVICE.getConfigValue("nodes.power_lora_loader.info_autoplay_video") && "autoplay") || ""}
loop
></video>`
: `<img src="${img.url}" />`
}
<menu>
<button class="rgthree-button-reset rgthree-info-btn-play-pause" data-action="video-play-pause">${play}${pause}</button>
<button class="rgthree-button-reset rgthree-info-btn-mute" data-action="video-toggle-mute">${volumeHigh}${volumeMute}</button>
</menu>
<figcaption><!--
-->${imgInfoField(
"",
img.civitaiUrl
? `<a href="${img.civitaiUrl}" target="_blank">civitai${link}</a>`
: undefined,
)}<!--
-->${imgInfoField("seed", img.seed)}<!--
-->${imgInfoField("steps", img.steps)}<!--
-->${imgInfoField("cfg", img.cfg)}<!--
-->${imgInfoField("sampler", img.sampler)}<!--
-->${imgInfoField("model", img.model)}<!--
-->${imgInfoField("positive", img.positive)}<!--
-->${imgInfoField("negative", img.negative)}<!--
--><!--${
""
// img.resources?.length
// ? `
// <tr><td>Resources</td><td><ul>
// ${(img.resources || [])
// .map(
// (r) => `
// <li>[${r.type || ""}] ${r.name || ""} ${
// r.weight != null ? `@ ${r.weight}` : ""
// }</li>
// `,
// )
// .join("")}
// </ul></td></tr>
// `
// : ""
}--></figcaption>
</figure>
</li>`,
)
.join("") ?? ""
}</ul>
`;
const div = $el("div", {html});
// Add functionality to the videos to play/pause and toggle mute.
for (const fig of queryAll("figure", div)) {
const video = query("video", fig);
if (!video) continue;
video.addEventListener("play", () => {
video.classList.add("-rgthree-is-playing");
});
video.addEventListener("pause", () => {
video.classList.remove("-rgthree-is-playing");
});
query('[data-action="video-play-pause"]', fig)!.addEventListener("click", () => {
const isPlaying = !!(
video.currentTime > 0 &&
!video.paused &&
!video.ended &&
video.readyState > 2
);
if (isPlaying) {
video.pause();
} else {
video.play();
}
});
video.volume = 0;
video.classList.add("-rgthree-is-muted");
query('[data-action="video-toggle-mute"]', fig)!.addEventListener("click", () => {
if (!video.volume) {
video.volume = 0.66;
video.classList.remove("-rgthree-is-muted");
} else {
video.volume = 0;
video.classList.add("-rgthree-is-muted");
}
});
}
if (rgthree.isDevMode()) {
setAttributes(query('[stub="menu"]', div)!, {
children: [
new MenuButton({
icon: dotdotdot,
options: [
{label: "More Actions", type: "title"},
{
label: "Open API JSON",
callback: async (e: PointerEvent) => {
if (this.modelInfo?.file) {
window.open(
`rgthree/api/loras/info?files=${encodeURIComponent(this.modelInfo.file)}`,
);
}
},
},
{
label: "Clear all local info",
callback: async (e: PointerEvent) => {
if (this.modelInfo?.file) {
this.modelInfo = await LORA_INFO_SERVICE.clearFetchedInfo(this.modelInfo.file);
this.setContent(this.getInfoContent());
this.setTitle(
this.modelInfo?.["name"] || this.modelInfo?.["file"] || "Unknown",
);
}
},
},
],
}),
],
});
}
return div;
}
}
export class RgthreeLoraInfoDialog extends RgthreeInfoDialog {
override async getModelInfo(file: string) {
return LORA_INFO_SERVICE.getInfo(file, false, false);
}
override async refreshModelInfo(file: string) {
return LORA_INFO_SERVICE.refreshInfo(file);
}
override async clearModelInfo(file: string) {
return LORA_INFO_SERVICE.clearFetchedInfo(file);
}
}
export class RgthreeCheckpointInfoDialog extends RgthreeInfoDialog {
override async getModelInfo(file: string) {
return CHECKPOINT_INFO_SERVICE.getInfo(file, false, false);
}
override async refreshModelInfo(file: string) {
return CHECKPOINT_INFO_SERVICE.refreshInfo(file);
}
override async clearModelInfo(file: string) {
return CHECKPOINT_INFO_SERVICE.clearFetchedInfo(file);
}
}
/**
* Generates a uniform markup string for a table row.
*/
function infoTableRow(
name: string,
value: string | number,
help: string = "",
editableFieldName = "",
) {
return `
<tr class="${editableFieldName ? "editable" : ""}" ${
editableFieldName ? `data-field-name="${editableFieldName}"` : ""
}>
<td><span>${name} ${help ? `<span class="-help" title="${help}"></span>` : ""}<span></td>
<td ${editableFieldName ? "" : 'colspan="2"'}>${
String(value).startsWith("<") ? value : `<span>${value}<span>`
}</td>
${
editableFieldName
? `<td style="width: 24px;"><button class="rgthree-button-reset rgthree-button-edit" data-action="edit-row">${pencilColored}${diskColored}</button></td>`
: ""
}
</tr>`;
}
function getTrainedWordsMarkup(words: RgthreeModelInfo["trainedWords"]) {
let markup = `<ul class="rgthree-info-trained-words-list">`;
for (const wordData of words || []) {
markup += `<li title="${wordData.word}" data-word="${
wordData.word
}" class="rgthree-info-trained-words-list-item" data-action="toggle-trained-word">
<span>${wordData.word}</span>
${wordData.civitai ? logoCivitai : ""}
${wordData.count != null ? `<small>${wordData.count}</small>` : ""}
</li>`;
}
markup += `</ul>`;
return markup;
}
/**
* Saves / cancels an editable row. Returns a boolean if the data was modified.
*/
function saveEditableRow(info: RgthreeModelInfo, tr: HTMLElement, saving = true): boolean {
const fieldName = tr.dataset["fieldName"] as "file";
const input = query<HTMLInputElement>("input,textarea", tr)!;
let newValue = info[fieldName] ?? "";
let modified = false;
if (saving) {
newValue = input!.value;
if (fieldName.startsWith("strength")) {
if (Number.isNaN(Number(newValue))) {
alert(`You must enter a number into the ${fieldName} field.`);
return false;
}
newValue = (Math.round(Number(newValue) * 100) / 100).toFixed(2);
}
LORA_INFO_SERVICE.savePartialInfo(info.file!, {[fieldName]: newValue});
modified = true;
}
tr.classList.remove("-rgthree-editing");
const td = query("td:nth-child(2)", tr)!;
appendChildren(empty(td), [$el("span", {text: newValue})]);
return modified;
}
function imgInfoField(label: string, value?: string | number) {
return value != null ? `<span>${label ? `<label>${label} </label>` : ""}${value}</span>` : "";
}
+74
View File
@@ -0,0 +1,74 @@
import type {
ComfyApp,
ComfyNodeDef,
LGraphNodeConstructor,
LGraphNode as TLGraphNode,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {addConnectionLayoutSupport} from "./utils.js";
import {rgthree} from "./rgthree.js";
let hasShownAlertForUpdatingInt = false;
app.registerExtension({
name: "rgthree.DisplayAny",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef, app: ComfyApp) {
if (nodeData.name === "Display Any (rgthree)" || nodeData.name === "Display Int (rgthree)") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined;
(this as any).showValueWidget = ComfyWidgets["STRING"](
this,
"output",
["STRING", {multiline: true}],
app,
).widget;
(this as any).showValueWidget.inputEl!.readOnly = true;
(this as any).showValueWidget.serializeValue = async (node: TLGraphNode, index: number) => {
const n =
rgthree.getNodeFromInitialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff(node);
if (n) {
// Since we need a round trip to get the value, the serizalized value means nothing, and
// saving it to the metadata would just be confusing. So, we clear it here.
n.widgets_values![index] = "";
} else {
console.warn(
"No serialized node found in workflow. May be attributed to " +
"https://github.com/comfyanonymous/ComfyUI/issues/2193",
);
}
return "";
};
};
addConnectionLayoutSupport(nodeType as LGraphNodeConstructor, app, [["Left"], ["Right"]]);
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message: any) {
onExecuted?.apply(this, [message]);
(this as any).showValueWidget.value = message.text[0];
};
}
},
// This ports Display Int to DisplayAny, but ComfyUI still shows an error.
// If https://github.com/comfyanonymous/ComfyUI/issues/1527 is fixed, this could work.
// async loadedGraphNode(node: TLGraphNode) {
// if (node.type === "Display Int (rgthree)") {
// replaceNode(node, "Display Any (rgthree)", new Map([["input", "source"]]));
// if (!hasShownAlertForUpdatingInt) {
// hasShownAlertForUpdatingInt = true;
// setTimeout(() => {
// alert(
// "Don't worry, your 'Display Int' nodes have been updated to the new " +
// "'Display Any' nodes! You can ignore the error message underneath (for that node)." +
// "\n\nThanks.\n- rgthree",
// );
// }, 128);
// }
// }
// },
});
+302
View File
@@ -0,0 +1,302 @@
import type {
ComfyNodeDef,
IContextMenuValue,
IFoundSlot,
INodeInputSlot,
INodeOutputSlot,
ISlotType,
LGraphNode,
LLink,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {
IoDirection,
followConnectionUntilType,
getConnectedInputInfosAndFilterPassThroughs,
} from "./utils.js";
import {rgthree} from "./rgthree.js";
import {
SERVICE as CONTEXT_SERVICE,
InputMutation,
InputMutationOperation,
} from "./services/context_service.js";
import {NodeTypesString} from "./constants.js";
import {removeUnusedInputsFromEnd} from "./utils_inputs_outputs.js";
import {DynamicContextNodeBase} from "./dynamic_context_base.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
const OWNED_PREFIX = "+";
const REGEX_OWNED_PREFIX = /^\+\s*/;
const REGEX_EMPTY_INPUT = /^\+\s*$/;
/**
* The Dynamic Context node.
*/
export class DynamicContextNode extends DynamicContextNodeBase {
static override title = NodeTypesString.DYNAMIC_CONTEXT;
static override type = NodeTypesString.DYNAMIC_CONTEXT;
static comfyClass = NodeTypesString.DYNAMIC_CONTEXT;
constructor(title = DynamicContextNode.title) {
super(title);
}
override onNodeCreated() {
this.addInput("base_ctx", "RGTHREE_DYNAMIC_CONTEXT");
this.ensureOneRemainingNewInputSlot();
super.onNodeCreated();
}
override onConnectionsChange(
type: ISlotType,
slotIndex: number,
isConnected: boolean,
link: LLink | null | undefined,
ioSlot: INodeInputSlot | INodeOutputSlot,
): void {
super.onConnectionsChange?.call(this, type, slotIndex, isConnected, link, ioSlot);
if (this.configuring) {
return;
}
if (type === LiteGraph.INPUT) {
if (isConnected) {
this.handleInputConnected(slotIndex);
} else {
this.handleInputDisconnected(slotIndex);
}
}
}
override onConnectInput(
inputIndex: number,
outputType: INodeOutputSlot["type"],
outputSlot: INodeOutputSlot,
outputNode: LGraphNode,
outputIndex: number,
): boolean {
let canConnect = true;
if (super.onConnectInput) {
canConnect = super.onConnectInput.apply(this, [...arguments] as any);
}
if (
canConnect &&
outputNode instanceof DynamicContextNode &&
outputIndex === 0 &&
inputIndex !== 0
) {
const [n, v] = rgthree.logger.warnParts(
"Currently, you can only connect a context node in the first slot.",
);
console[n]?.call(console, ...v);
canConnect = false;
}
return canConnect;
}
handleInputConnected(slotIndex: number) {
const ioSlot = this.inputs[slotIndex];
const connectedIndexes = [];
if (slotIndex === 0) {
let baseNodeInfos = getConnectedInputInfosAndFilterPassThroughs(this, this, 0);
const baseNodes = baseNodeInfos.map((n) => n.node)!;
const baseNodesDynamicCtx = baseNodes[0] as DynamicContextNodeBase;
if (baseNodesDynamicCtx?.provideInputsData) {
const inputsData = CONTEXT_SERVICE.getDynamicContextInputsData(baseNodesDynamicCtx);
console.log("inputsData", inputsData);
for (const input of baseNodesDynamicCtx.provideInputsData()) {
if (input.name === "base_ctx" || input.name === "+") {
continue;
}
this.addContextInput(input.name, input.type, input.index);
this.stabilizeNames();
}
}
} else if (this.isInputSlotForNewInput(slotIndex)) {
this.handleNewInputConnected(slotIndex);
}
}
isInputSlotForNewInput(slotIndex: number) {
const ioSlot = this.inputs[slotIndex];
return ioSlot && ioSlot.name === "+" && ioSlot.type === "*";
}
handleNewInputConnected(slotIndex: number) {
if (!this.isInputSlotForNewInput(slotIndex)) {
throw new Error('Expected the incoming slot index to be the "new input" input.');
}
const ioSlot = this.inputs[slotIndex]!;
let cxn = null;
if (ioSlot.link != null) {
cxn = followConnectionUntilType(this, IoDirection.INPUT, slotIndex, true);
}
if (cxn?.type && cxn?.name) {
let name = this.addOwnedPrefix(this.getNextUniqueNameForThisNode(cxn.name));
if (name.match(/^\+\s*[A-Z_]+(\.\d+)?$/)) {
name = name.toLowerCase();
}
ioSlot.name = name;
ioSlot.type = cxn.type as string;
ioSlot.removable = true;
while (!this.outputs[slotIndex]) {
this.addOutput("*", "*");
}
this.outputs[slotIndex]!.type = cxn.type as string;
this.outputs[slotIndex]!.name = this.stripOwnedPrefix(name).toLocaleUpperCase();
// This is a dumb override for ComfyUI's widgetinputs issues.
if (cxn.type === "COMBO" || cxn.type.includes(",") || Array.isArray(cxn.type)) {
(this.outputs[slotIndex] as any).widget = true;
}
this.inputsMutated({
operation: InputMutationOperation.ADDED,
node: this,
slotIndex,
slot: ioSlot,
});
this.stabilizeNames();
this.ensureOneRemainingNewInputSlot();
}
}
handleInputDisconnected(slotIndex: number) {
const inputs = this.getContextInputsList();
if (slotIndex === 0) {
for (let index = inputs.length - 1; index > 0; index--) {
if (index === 0 || index === inputs.length - 1) {
continue;
}
const input = inputs[index]!;
if (!this.isOwnedInput(input.name)) {
if (input.link || this.outputs[index]?.links?.length) {
this.renameContextInput(index, input.name, true);
} else {
this.removeContextInput(index);
}
}
}
this.setSize(this.computeSize());
this.setDirtyCanvas(true, true);
}
}
ensureOneRemainingNewInputSlot() {
removeUnusedInputsFromEnd(this, 1, REGEX_EMPTY_INPUT);
this.addInput(OWNED_PREFIX, "*");
}
getNextUniqueNameForThisNode(desiredName: string) {
const inputs = this.getContextInputsList();
const allExistingKeys = inputs.map((i) => this.stripOwnedPrefix(i.name).toLocaleUpperCase());
desiredName = this.stripOwnedPrefix(desiredName);
let newName = desiredName;
let n = 0;
while (allExistingKeys.includes(newName.toLocaleUpperCase())) {
newName = `${desiredName}.${++n}`;
}
return newName;
}
override removeInput(slotIndex: number) {
const slot = this.inputs[slotIndex]!;
super.removeInput(slotIndex);
if (this.outputs[slotIndex]) {
this.removeOutput(slotIndex);
}
this.inputsMutated({operation: InputMutationOperation.REMOVED, node: this, slotIndex, slot});
this.stabilizeNames();
}
stabilizeNames() {
const inputs = this.getContextInputsList();
const names: string[] = [];
for (const [index, input] of inputs.entries()) {
if (index === 0 || index === inputs.length - 1) {
continue;
}
input.label = undefined;
this.outputs[index]!.label = undefined;
let origName = this.stripOwnedPrefix(input.name).replace(/\.\d+$/, "");
let name = input.name;
if (!this.isOwnedInput(name)) {
names.push(name.toLocaleUpperCase());
} else {
let n = 0;
name = this.addOwnedPrefix(origName);
while (names.includes(this.stripOwnedPrefix(name).toLocaleUpperCase())) {
name = `${this.addOwnedPrefix(origName)}.${++n}`;
}
names.push(this.stripOwnedPrefix(name).toLocaleUpperCase());
if (input.name !== name) {
this.renameContextInput(index, name);
}
}
}
}
override getSlotMenuOptions(slot: IFoundSlot): IContextMenuValue[] {
const editable = this.isOwnedInput(slot.input!.name) && this.type !== "*";
return [
{
content: "✏️ Rename Input",
disabled: !editable,
callback: () => {
var dialog = app.canvas.createDialog(
"<span class='name'>Name</span><input autofocus type='text'/><button>OK</button>",
{},
);
var dialogInput = dialog.querySelector("input")!;
if (dialogInput) {
dialogInput.value = this.stripOwnedPrefix(slot.input!.name || "");
}
var inner = () => {
this.handleContextMenuRenameInputDialog(slot.slot, dialogInput.value);
dialog.close();
};
dialog.querySelector("button")!.addEventListener("click", inner);
dialogInput.addEventListener("keydown", (e) => {
dialog.is_modified = true;
if (e.keyCode == 27) {
dialog.close();
} else if (e.keyCode == 13) {
inner();
} else if (e.keyCode != 13 && (e.target as HTMLElement)?.localName != "textarea") {
return;
}
e.preventDefault();
e.stopPropagation();
});
dialogInput.focus();
},
},
{
content: "🗑️ Delete Input",
disabled: !editable,
callback: () => {
this.removeInput(slot.slot);
},
},
];
}
handleContextMenuRenameInputDialog(slotIndex: number, value: string) {
app.graph.beforeChange();
this.renameContextInput(slotIndex, value);
this.stabilizeNames();
this.setDirtyCanvas(true, true);
app.graph.afterChange();
}
}
const contextDynamicNodes = [DynamicContextNode];
app.registerExtension({
name: "rgthree.DynamicContext",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (!CONFIG_SERVICE.getConfigValue("unreleased.dynamic_context.enabled")) {
return;
}
if (nodeData.name === DynamicContextNode.type) {
DynamicContextNode.setUp(nodeType, nodeData);
}
},
});
+240
View File
@@ -0,0 +1,240 @@
import type {ComfyNodeDef, INodeInputSlot, LGraphNodeConstructor, LinkId} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {BaseContextNode} from "./context.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {moveArrayItem, wait} from "rgthree/common/shared_utils.js";
import {RgthreeInvisibleWidget} from "./utils_widgets.js";
import {
getContextOutputName,
InputMutation,
InputMutationOperation,
} from "./services/context_service.js";
import {SERVICE as CONTEXT_SERVICE} from "./services/context_service.js";
const OWNED_PREFIX = "+";
const REGEX_OWNED_PREFIX = /^\+\s*/;
const REGEX_EMPTY_INPUT = /^\+\s*$/;
export type InputLike = {
name: string;
type: number | string;
label?: string;
link: LinkId | null;
removable?: boolean;
boundingRect: any;
};
/**
* The base context node that contains some shared between DynamicContext nodes. Not labels
* `abstract` so we can reference `this` in static methods.
*/
export class DynamicContextNodeBase extends BaseContextNode {
protected readonly hasShadowInputs: boolean = false;
getContextInputsList(): InputLike[] {
return this.inputs;
}
provideInputsData() {
const inputs = this.getContextInputsList();
return inputs
.map((input, index) => ({
name: this.stripOwnedPrefix(input.name),
type: String(input.type),
index,
}))
.filter((i) => i.type !== "*");
}
addOwnedPrefix(name: string) {
return `+ ${this.stripOwnedPrefix(name)}`;
}
isOwnedInput(inputOrName: string | null | INodeInputSlot) {
const name = typeof inputOrName == "string" ? inputOrName : inputOrName?.name || "";
return REGEX_OWNED_PREFIX.test(name);
}
stripOwnedPrefix(name: string) {
return name.replace(REGEX_OWNED_PREFIX, "");
}
// handleUpstreamMutation(mutation: InputMutation) {
// throw new Error('handleUpstreamMutation not overridden!')
// }
handleUpstreamMutation(mutation: InputMutation) {
console.log(`[node ${this.id}] handleUpstreamMutation`, mutation);
if (mutation.operation === InputMutationOperation.ADDED) {
const slot = mutation.slot;
if (!slot) {
throw new Error("Cannot have an ADDED mutation without a provided slot data.");
}
this.addContextInput(
this.stripOwnedPrefix(slot.name),
slot.type as string,
mutation.slotIndex,
);
return;
}
if (mutation.operation === InputMutationOperation.REMOVED) {
const slot = mutation.slot;
if (!slot) {
throw new Error("Cannot have an REMOVED mutation without a provided slot data.");
}
this.removeContextInput(mutation.slotIndex);
return;
}
if (mutation.operation === InputMutationOperation.RENAMED) {
const slot = mutation.slot;
if (!slot) {
throw new Error("Cannot have an RENAMED mutation without a provided slot data.");
}
this.renameContextInput(mutation.slotIndex, slot.name);
return;
}
}
override clone() {
const cloned = super.clone()! as DynamicContextNodeBase;
while (cloned.inputs.length > 1) {
cloned.removeInput(cloned.inputs.length - 1);
}
while (cloned.widgets.length > 1) {
cloned.removeWidget(cloned.widgets.length - 1);
}
while (cloned.outputs.length > 1) {
cloned.removeOutput(cloned.outputs.length - 1);
}
return cloned;
}
/**
* Adds the basic output_keys widget. Should be called _after_ specific nodes setup their inputs
* or widgets.
*/
override onNodeCreated() {
const node = this;
this.addCustomWidget(
new RgthreeInvisibleWidget("output_keys", "RGTHREE_DYNAMIC_CONTEXT_OUTPUTS", "", () => {
return (node.outputs || [])
.map((o, i) => i > 0 && o.name)
.filter((n) => n !== false)
.join(",");
}),
);
}
addContextInput(name: string, type: string, slot = -1) {
const inputs = this.getContextInputsList();
if (this.hasShadowInputs) {
inputs.push({name, type, link: null, boundingRect: null});
} else {
this.addInput(name, type);
}
if (slot > -1) {
moveArrayItem(inputs, inputs.length - 1, slot);
} else {
slot = inputs.length - 1;
}
if (type !== "*") {
const output = this.addOutput(getContextOutputName(name), type);
if (type === "COMBO" || String(type).includes(",") || Array.isArray(type)) {
(output as any).widget = true;
}
if (slot > -1) {
moveArrayItem(this.outputs, this.outputs.length - 1, slot);
}
}
this.fixInputsOutputsLinkSlots();
this.inputsMutated({
operation: InputMutationOperation.ADDED,
node: this,
slotIndex: slot,
slot: inputs[slot]!,
});
}
removeContextInput(slotIndex: number) {
if (this.hasShadowInputs) {
const inputs = this.getContextInputsList();
const input = inputs.splice(slotIndex, 1)[0];
if (this.outputs[slotIndex]) {
this.removeOutput(slotIndex);
}
} else {
this.removeInput(slotIndex);
}
}
renameContextInput(index: number, newName: string, forceOwnBool: boolean | null = null) {
const inputs = this.getContextInputsList();
const input = inputs[index]!;
const oldName = input.name;
newName = this.stripOwnedPrefix(newName.trim() || this.getSlotDefaultInputLabel(index));
if (forceOwnBool === true || (this.isOwnedInput(oldName) && forceOwnBool !== false)) {
newName = this.addOwnedPrefix(newName);
}
if (oldName !== newName) {
input.name = newName;
input.removable = this.isOwnedInput(newName);
this.outputs[index]!.name = getContextOutputName(inputs[index]!.name);
this.inputsMutated({
node: this,
operation: InputMutationOperation.RENAMED,
slotIndex: index,
slot: input,
});
}
}
getSlotDefaultInputLabel(slotIndex: number) {
const inputs = this.getContextInputsList();
const input = inputs[slotIndex]!;
let defaultLabel = this.stripOwnedPrefix(input.name).toLowerCase();
return defaultLabel.toLocaleLowerCase();
}
inputsMutated(mutation: InputMutation) {
CONTEXT_SERVICE.onInputChanges(this, mutation);
}
fixInputsOutputsLinkSlots() {
if (!this.hasShadowInputs) {
const inputs = this.getContextInputsList();
for (let index = inputs.length - 1; index > 0; index--) {
const input = inputs[index]!;
if (input?.link != null) {
app.graph.links[input.link]!.target_slot = index;
}
}
}
const outputs = this.outputs;
for (let index = outputs.length - 1; index > 0; index--) {
const output = outputs[index];
if (output) {
output.nameLocked = true;
for (const link of output.links || []) {
app.graph.links[link!]!.origin_slot = index;
}
}
}
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, this);
// [🤮] ComfyUI only adds "required" inputs to the outputs list when dragging an output to
// empty space, but since RGTHREE_CONTEXT is optional, it doesn't get added to the menu because
// ...of course. So, we'll manually add it. Of course, we also have to do this in a timeout
// because ComfyUI clears out `LiteGraph.slot_types_default_out` in its own 'Comfy.SlotDefaults'
// extension and we need to wait for that to happen.
wait(500).then(() => {
LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"] =
LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"] || [];
const comfyClassStr = (comfyClass as LGraphNodeConstructor).comfyClass;
if (comfyClassStr) {
LiteGraph.slot_types_default_out["RGTHREE_DYNAMIC_CONTEXT"].push(comfyClassStr);
}
});
}
}
+214
View File
@@ -0,0 +1,214 @@
import type {
ComfyNodeDef,
LGraphNode,
LLink,
LGraphCanvas,
INodeInputSlot,
INodeOutputSlot,
ISlotType,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {DynamicContextNodeBase, InputLike} from "./dynamic_context_base.js";
import {NodeTypesString} from "./constants.js";
import {
InputMutation,
SERVICE as CONTEXT_SERVICE,
getContextOutputName,
} from "./services/context_service.js";
import {getConnectedInputNodesAndFilterPassThroughs} from "./utils.js";
import {debounce, moveArrayItem} from "rgthree/common/shared_utils.js";
import {measureText} from "./utils_canvas.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
type ShadowInputData = {
node: LGraphNode;
slot: number;
shadowIndex: number;
shadowIndexIfShownSingularly: number;
shadowIndexFull: number;
nodeIndex: number;
type: string | -1;
name: string;
key: string;
// isDuplicatedBefore: boolean,
duplicatesBefore: number[];
duplicatesAfter: number[];
};
/**
* The Context Switch node.
*/
class DynamicContextSwitchNode extends DynamicContextNodeBase {
static override title = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
static override type = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
static comfyClass = NodeTypesString.DYNAMIC_CONTEXT_SWITCH;
protected override readonly hasShadowInputs = true;
// override hasShadowInputs = true;
/**
* We should be able to assume that `lastInputsList` is the input list after the last, major
* synchronous change. Which should mean, if we're handling a change that is currently live, but
* not represented in our node (like, an upstream node has already removed an input), then we
* should be able to compar the current InputList to this `lastInputsList`.
*/
lastInputsList: ShadowInputData[] = [];
private shadowInputs: (InputLike & {count: number})[] = [
{name: "base_ctx", type: "RGTHREE_DYNAMIC_CONTEXT", link: null, count: 0, boundingRect: null},
];
constructor(title = DynamicContextSwitchNode.title) {
super(title);
}
override getContextInputsList() {
return this.shadowInputs;
}
override handleUpstreamMutation(mutation: InputMutation) {
this.scheduleHardRefresh();
}
override onConnectionsChange(
type: ISlotType,
slotIndex: number,
isConnected: boolean,
link: LLink | null | undefined,
inputOrOutput: INodeInputSlot | INodeOutputSlot,
): void {
super.onConnectionsChange?.call(this, type, slotIndex, isConnected, link, inputOrOutput);
if (this.configuring) {
return;
}
if (type === LiteGraph.INPUT) {
this.scheduleHardRefresh();
}
}
scheduleHardRefresh(ms = 64) {
return debounce(() => {
this.refreshInputsAndOutputs();
}, ms);
}
override onNodeCreated() {
this.addInput("ctx_1", "RGTHREE_DYNAMIC_CONTEXT");
this.addInput("ctx_2", "RGTHREE_DYNAMIC_CONTEXT");
this.addInput("ctx_3", "RGTHREE_DYNAMIC_CONTEXT");
this.addInput("ctx_4", "RGTHREE_DYNAMIC_CONTEXT");
this.addInput("ctx_5", "RGTHREE_DYNAMIC_CONTEXT");
super.onNodeCreated();
}
override addContextInput(name: string, type: string, slot?: number): void {}
/**
* This is a "hard" refresh of the list, but looping over the actual context inputs, and
* recompiling the shadowInputs and outputs.
*/
private refreshInputsAndOutputs() {
const inputs: (InputLike & {count: number})[] = [
{name: "base_ctx", type: "RGTHREE_DYNAMIC_CONTEXT", link: null, count: 0, boundingRect: null},
];
let numConnected = 0;
for (let i = 0; i < this.inputs.length; i++) {
const childCtxs = getConnectedInputNodesAndFilterPassThroughs(
this,
this,
i,
) as DynamicContextNodeBase[];
if (childCtxs.length > 1) {
throw new Error("How is there more than one input?");
}
const ctx = childCtxs[0];
if (!ctx) continue;
numConnected++;
const slotsData = CONTEXT_SERVICE.getDynamicContextInputsData(ctx);
console.log(slotsData);
for (const slotData of slotsData) {
const found = inputs.find(
(n) => getContextOutputName(slotData.name) === getContextOutputName(n.name),
);
if (found) {
found.count += 1;
continue;
}
inputs.push({
name: slotData.name,
type: slotData.type,
link: null,
count: 1,
boundingRect: null,
});
}
}
this.shadowInputs = inputs;
// First output is always CONTEXT, so "p" is the offset.
let i = 0;
for (i; i < this.shadowInputs.length; i++) {
const data = this.shadowInputs[i]!;
let existing = this.outputs.find(
(o) => getContextOutputName(o.name) === getContextOutputName(data.name),
);
if (!existing) {
existing = this.addOutput(getContextOutputName(data.name), data.type);
}
moveArrayItem(this.outputs, existing, i);
delete existing.rgthree_status;
if (data.count !== numConnected) {
existing.rgthree_status = "WARN";
}
}
while (this.outputs[i]) {
const output = this.outputs[i];
if (output?.links?.length) {
output.rgthree_status = "ERROR";
i++;
} else {
this.removeOutput(i);
}
}
this.fixInputsOutputsLinkSlots();
}
override onDrawForeground(ctx: CanvasRenderingContext2D, canvas: LGraphCanvas): void {
const low_quality = (canvas?.ds?.scale ?? 1) < 0.6;
if (low_quality || this.size[0] <= 10) {
return;
}
let y = LiteGraph.NODE_SLOT_HEIGHT - 1;
const w = this.size[0];
ctx.save();
ctx.font = "normal " + LiteGraph.NODE_SUBTEXT_SIZE + "px Arial";
ctx.textAlign = "right";
for (const output of this.outputs) {
if (!output.rgthree_status) {
y += LiteGraph.NODE_SLOT_HEIGHT;
continue;
}
const x = w - 20 - measureText(ctx, output.name);
if (output.rgthree_status === "ERROR") {
ctx.fillText("🛑", x, y);
} else if (output.rgthree_status === "WARN") {
ctx.fillText("⚠️", x, y);
}
y += LiteGraph.NODE_SLOT_HEIGHT;
}
ctx.restore();
}
}
app.registerExtension({
name: "rgthree.DynamicContextSwitch",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (!CONFIG_SERVICE.getConfigValue("unreleased.dynamic_context.enabled")) {
return;
}
if (nodeData.name === DynamicContextSwitchNode.type) {
DynamicContextSwitchNode.setUp(nodeType, nodeData);
}
},
});
+359
View File
@@ -0,0 +1,359 @@
import type {
LGraph,
LGraphNode,
ISerialisedNode,
IButtonWidget,
IComboWidget,
IWidget,
IBaseWidget,
} from "@comfyorg/frontend";
import type {ComfyApp} from "@comfyorg/frontend";
import type {RgthreeBaseVirtualNode} from "./base_node.js";
import {app} from "scripts/app.js";
import {BaseAnyInputConnectedNode} from "./base_any_input_connected_node.js";
import {NodeTypesString} from "./constants.js";
import {addMenuItem, changeModeOfNodes} from "./utils.js";
import {rgthree} from "./rgthree.js";
const MODE_ALWAYS = 0;
const MODE_MUTE = 2;
const MODE_BYPASS = 4;
/**
* The Fast Actions Button.
*
* This adds a button that the user can connect any node to and then choose an action to take on
* that node when the button is pressed. Default actions are "Mute," "Bypass," and "Enable," but
* Nodes can expose actions additional actions that can then be called back.
*/
class FastActionsButton extends BaseAnyInputConnectedNode {
static override type = NodeTypesString.FAST_ACTIONS_BUTTON;
static override title = NodeTypesString.FAST_ACTIONS_BUTTON;
override comfyClass = NodeTypesString.FAST_ACTIONS_BUTTON;
readonly logger = rgthree.newLogSession("[FastActionsButton]");
static "@buttonText" = {type: "string"};
static "@shortcutModifier" = {
type: "combo",
values: ["ctrl", "alt", "shift"],
};
static "@shortcutKey" = {type: "string"};
static collapsible = false;
override readonly isVirtualNode = true;
override serialize_widgets = true;
readonly buttonWidget: IButtonWidget;
readonly widgetToData = new Map<IWidget, {comfy?: ComfyApp; node?: LGraphNode}>();
readonly nodeIdtoFunctionCache = new Map<number, string>();
readonly keypressBound;
readonly keyupBound;
private executingFromShortcut = false;
override properties!: BaseAnyInputConnectedNode["properties"] & {
buttonText: string;
shortcutModifier: string;
shortcutKey: string;
};
constructor(title?: string) {
super(title);
this.properties["buttonText"] = "🎬 Action!";
this.properties["shortcutModifier"] = "alt";
this.properties["shortcutKey"] = "";
this.buttonWidget = this.addWidget(
"button",
this.properties["buttonText"],
"",
() => {
this.executeConnectedNodes();
},
{serialize: false},
) as IButtonWidget;
this.keypressBound = this.onKeypress.bind(this);
this.keyupBound = this.onKeyup.bind(this);
this.onConstructed();
}
/** When we're given data to configure, like from a PNG or JSON. */
override configure(info: ISerialisedNode): void {
super.configure(info);
// Since we add the widgets dynamically, we need to wait to set their values
// with a short timeout.
setTimeout(() => {
if (info.widgets_values) {
for (let [index, value] of info.widgets_values.entries()) {
if (index > 0) {
if (typeof value === "string" && value.startsWith("comfy_action:")) {
value = value.replace("comfy_action:", "");
this.addComfyActionWidget(index, value);
}
if (this.widgets[index]) {
this.widgets[index]!.value = value;
}
}
}
}
}, 100);
}
override clone() {
const cloned = super.clone()!;
cloned.properties["buttonText"] = "🎬 Action!";
cloned.properties["shortcutKey"] = "";
return cloned;
}
override onAdded(graph: LGraph): void {
window.addEventListener("keydown", this.keypressBound);
window.addEventListener("keyup", this.keyupBound);
}
override onRemoved(): void {
window.removeEventListener("keydown", this.keypressBound);
window.removeEventListener("keyup", this.keyupBound);
}
async onKeypress(event: KeyboardEvent) {
const target = (event.target as HTMLElement)!;
if (
this.executingFromShortcut ||
target.localName == "input" ||
target.localName == "textarea"
) {
return;
}
if (
this.properties["shortcutKey"].trim() &&
this.properties["shortcutKey"].toLowerCase() === event.key.toLowerCase()
) {
const shortcutModifier = this.properties["shortcutModifier"];
let good = shortcutModifier === "ctrl" && event.ctrlKey;
good = good || (shortcutModifier === "alt" && event.altKey);
good = good || (shortcutModifier === "shift" && event.shiftKey);
good = good || (shortcutModifier === "meta" && event.metaKey);
if (good) {
setTimeout(() => {
this.executeConnectedNodes();
}, 20);
this.executingFromShortcut = true;
event.preventDefault();
event.stopImmediatePropagation();
app.canvas.dirty_canvas = true;
return false;
}
}
return;
}
onKeyup(event: KeyboardEvent) {
const target = (event.target as HTMLElement)!;
if (target.localName == "input" || target.localName == "textarea") {
return;
}
this.executingFromShortcut = false;
}
override onPropertyChanged(property: string, value: unknown, prevValue?: unknown) {
if (property == "buttonText" && typeof value === "string") {
this.buttonWidget.name = value;
}
if (property == "shortcutKey" && typeof value === "string") {
this.properties["shortcutKey"] = value.trim()[0]?.toLowerCase() ?? "";
}
return true;
}
override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) {
let changed = false;
// Remove any widgets and data for widgets that are no longer linked.
for (const [widget, data] of this.widgetToData.entries()) {
if (!data.node) {
continue;
}
if (!linkedNodes.includes(data.node)) {
const index = this.widgets.indexOf(widget);
if (index > -1) {
this.widgetToData.delete(widget);
this.removeWidget(widget);
changed = true;
} else {
const [m, a] = this.logger.debugParts("Connected widget is not in widgets... weird.");
console[m]?.(...a);
}
}
}
const badNodes: LGraphNode[] = []; // Nodes that are deleted elsewhere may not exist in linkedNodes.
let indexOffset = 1; // Start with button, increment when we hit a non-node widget (like comfy)
for (const [index, node] of linkedNodes.entries()) {
// Sometimes linkedNodes is stale.
if (!node) {
const [m, a] = this.logger.debugParts("linkedNode provided that does not exist. ");
console[m]?.(...a);
badNodes.push(node);
continue;
}
let widgetAtSlot = this.widgets[index + indexOffset];
if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) {
indexOffset++;
widgetAtSlot = this.widgets[index + indexOffset];
}
if (!widgetAtSlot || this.widgetToData.get(widgetAtSlot)?.node?.id !== node.id) {
// Find the next widget that matches the node.
let widget: IWidget | null = null;
for (let i = index + indexOffset; i < this.widgets.length; i++) {
if (this.widgetToData.get(this.widgets[i]!)?.node?.id === node.id) {
widget = this.widgets.splice(i, 1)[0]!;
this.widgets.splice(index + indexOffset, 0, widget);
changed = true;
break;
}
}
if (!widget) {
// Add a widget at this spot.
const exposedActions: string[] = (node.constructor as any).exposedActions || [];
widget = this.addWidget("combo", node.title, "None", "", {
values: ["None", "Mute", "Bypass", "Enable", ...exposedActions],
}) as IWidget;
widget.serializeValue = async (_node: LGraphNode, _index: number) => {
return widget?.value;
};
this.widgetToData.set(widget, {node});
changed = true;
}
}
}
// Go backwards through widgets, and remove any that are not in out widgetToData
for (let i = this.widgets.length - 1; i > linkedNodes.length + indexOffset - 1; i--) {
const widgetAtSlot = this.widgets[i];
if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) {
continue;
}
this.removeWidget(widgetAtSlot);
changed = true;
}
return changed;
}
override removeWidget(widget: IBaseWidget | IWidget | number | undefined): void {
widget = typeof widget === "number" ? this.widgets[widget] : widget;
if (widget && this.widgetToData.has(widget as IWidget)) {
this.widgetToData.delete(widget as IWidget);
}
super.removeWidget(widget);
}
/**
* Runs through the widgets, and executes the actions.
*/
async executeConnectedNodes() {
for (const widget of this.widgets) {
if (widget == this.buttonWidget) {
continue;
}
const action = widget.value;
const {comfy, node} = this.widgetToData.get(widget) ?? {};
if (comfy) {
if (action === "Queue Prompt") {
await comfy.queuePrompt(0);
}
continue;
}
if (node) {
if (action === "Mute") {
changeModeOfNodes(node, MODE_MUTE);
} else if (action === "Bypass") {
changeModeOfNodes(node, MODE_BYPASS);
} else if (action === "Enable") {
changeModeOfNodes(node, MODE_ALWAYS);
}
// If there's a handleAction, always call it.
if ((node as RgthreeBaseVirtualNode).handleAction) {
if (typeof action !== "string") {
throw new Error("Fast Actions Button action should be a string: " + action);
}
await (node as RgthreeBaseVirtualNode).handleAction(action);
}
this.graph?.change();
continue;
}
console.warn("Fast Actions Button has a widget without correct data.");
}
}
/**
* Adds a ComfyActionWidget at the provided slot (or end).
*/
addComfyActionWidget(slot?: number, value?: string) {
let widget = this.addWidget(
"combo",
"Comfy Action",
"None",
() => {
if (String(widget.value).startsWith("MOVE ")) {
this.widgets.push(this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!);
widget.value = String(widget.rgthree_lastValue);
} else if (String(widget.value).startsWith("REMOVE ")) {
this.removeWidget(widget);
}
widget.rgthree_lastValue = widget.value;
},
{
values: ["None", "Queue Prompt", "REMOVE Comfy Action", "MOVE to end"],
},
) as IComboWidget;
widget.rgthree_lastValue = value;
widget.serializeValue = async (_node: LGraphNode, _index: number) => {
return `comfy_app:${widget?.value}`;
};
this.widgetToData.set(widget, {comfy: app});
if (slot != null) {
this.widgets.splice(slot, 0, this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!);
}
return widget;
}
override onSerialize(serialised: ISerialisedNode) {
super.onSerialize?.(serialised);
for (let [index, value] of (serialised.widgets_values || []).entries()) {
if (this.widgets[index]?.name === "Comfy Action") {
serialised.widgets_values![index] = `comfy_action:${value}`;
}
}
}
static override setUp() {
super.setUp();
addMenuItem(this, app, {
name: "➕ Append a Comfy Action",
callback: (nodeArg: LGraphNode) => {
(nodeArg as FastActionsButton).addComfyActionWidget();
},
});
}
}
app.registerExtension({
name: "rgthree.FastActionsButton",
registerCustomNodes() {
FastActionsButton.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == FastActionsButton.title) {
(node as FastActionsButton)._tempWidth = node.size[0];
}
},
});
+38
View File
@@ -0,0 +1,38 @@
import type {Size} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {NodeTypesString} from "./constants.js";
import {BaseFastGroupsModeChanger} from "./fast_groups_muter.js";
/**
* Fast Bypasser implementation that looks for groups in the workflow and adds toggles to mute them.
*/
export class FastGroupsBypasser extends BaseFastGroupsModeChanger {
static override type = NodeTypesString.FAST_GROUPS_BYPASSER;
static override title = NodeTypesString.FAST_GROUPS_BYPASSER;
override comfyClass = NodeTypesString.FAST_GROUPS_BYPASSER;
static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
protected override helpActions = "bypass and enable";
override readonly modeOn = LiteGraph.ALWAYS;
override readonly modeOff = 4; // Used by Comfy for "bypass"
constructor(title = FastGroupsBypasser.title) {
super(title);
this.onConstructed();
}
}
app.registerExtension({
name: "rgthree.FastGroupsBypasser",
registerCustomNodes() {
FastGroupsBypasser.setUp();
},
loadedGraphNode(node: FastGroupsBypasser) {
if (node.type == FastGroupsBypasser.title) {
node.tempSize = [...node.size] as Size;
}
},
});
+542
View File
@@ -0,0 +1,542 @@
import type {
LGraphNode,
LGraph as TLGraph,
LGraphCanvas as TLGraphCanvas,
Vector2,
Size,
LGraphGroup,
CanvasMouseEvent,
Point,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {SERVICE as FAST_GROUPS_SERVICE} from "./services/fast_groups_service.js";
import {drawNodeWidget, fitString} from "./utils_canvas.js";
import {RgthreeBaseWidget} from "./utils_widgets.js";
import { changeModeOfNodes, getGroupNodes } from "./utils.js";
const PROPERTY_SORT = "sort";
const PROPERTY_SORT_CUSTOM_ALPHA = "customSortAlphabet";
const PROPERTY_MATCH_COLORS = "matchColors";
const PROPERTY_MATCH_TITLE = "matchTitle";
const PROPERTY_SHOW_NAV = "showNav";
const PROPERTY_SHOW_ALL_GRAPHS = "showAllGraphs";
const PROPERTY_RESTRICTION = "toggleRestriction";
/**
* Fast Muter implementation that looks for groups in the workflow and adds toggles to mute them.
*/
export abstract class BaseFastGroupsModeChanger extends RgthreeBaseVirtualNode {
static override type = NodeTypesString.FAST_GROUPS_MUTER;
static override title = NodeTypesString.FAST_GROUPS_MUTER;
static override exposedActions = ["Mute all", "Enable all", "Toggle all"];
readonly modeOn: number = LiteGraph.ALWAYS;
readonly modeOff: number = LiteGraph.NEVER;
private debouncerTempWidth: number = 0;
tempSize: Vector2 | null = null;
// We don't need to serizalize since we'll just be checking group data on startup anyway
override serialize_widgets = false;
protected helpActions = "mute and unmute";
static "@matchColors" = {type: "string"};
static "@matchTitle" = {type: "string"};
static "@showNav" = {type: "boolean"};
static "@showAllGraphs" = {type: "boolean"};
static "@sort" = {
type: "combo",
values: ["position", "alphanumeric", "custom alphabet"],
};
static "@customSortAlphabet" = {type: "string"};
override properties!: RgthreeBaseVirtualNode["properties"] & {
[PROPERTY_MATCH_COLORS]: string;
[PROPERTY_MATCH_TITLE]: string;
[PROPERTY_SHOW_NAV]: boolean;
[PROPERTY_SHOW_ALL_GRAPHS]: boolean;
[PROPERTY_SORT]: string;
[PROPERTY_SORT_CUSTOM_ALPHA]: string;
[PROPERTY_RESTRICTION]: string;
};
static "@toggleRestriction" = {
type: "combo",
values: ["default", "max one", "always one"],
};
constructor(title = FastGroupsMuter.title) {
super(title);
this.properties[PROPERTY_MATCH_COLORS] = "";
this.properties[PROPERTY_MATCH_TITLE] = "";
this.properties[PROPERTY_SHOW_NAV] = true;
this.properties[PROPERTY_SHOW_ALL_GRAPHS] = true;
this.properties[PROPERTY_SORT] = "position";
this.properties[PROPERTY_SORT_CUSTOM_ALPHA] = "";
this.properties[PROPERTY_RESTRICTION] = "default";
}
override onConstructed(): boolean {
this.addOutput("OPT_CONNECTION", "*");
return super.onConstructed();
}
override onAdded(graph: TLGraph): void {
FAST_GROUPS_SERVICE.addFastGroupNode(this);
}
override onRemoved(): void {
FAST_GROUPS_SERVICE.removeFastGroupNode(this);
}
refreshWidgets() {
const canvas = app.canvas as TLGraphCanvas;
let sort = this.properties?.[PROPERTY_SORT] || "position";
let customAlphabet: string[] | null = null;
if (sort === "custom alphabet") {
const customAlphaStr = this.properties?.[PROPERTY_SORT_CUSTOM_ALPHA]?.replace(/\n/g, "");
if (customAlphaStr && customAlphaStr.trim()) {
customAlphabet = customAlphaStr.includes(",")
? customAlphaStr.toLocaleLowerCase().split(",")
: customAlphaStr.toLocaleLowerCase().trim().split("");
}
if (!customAlphabet?.length) {
sort = "alphanumeric";
customAlphabet = null;
}
}
const groups = [...FAST_GROUPS_SERVICE.getGroups(sort)];
// The service will return pre-sorted groups for alphanumeric and position. If this node has a
// custom sort, then we need to sort it manually.
if (customAlphabet?.length) {
groups.sort((a, b) => {
let aIndex = -1;
let bIndex = -1;
// Loop and find indexes. As we're finding multiple, a single for loop is more efficient.
for (const [index, alpha] of customAlphabet!.entries()) {
aIndex =
aIndex < 0 ? (a.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : aIndex;
bIndex =
bIndex < 0 ? (b.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : bIndex;
if (aIndex > -1 && bIndex > -1) {
break;
}
}
// Now compare.
if (aIndex > -1 && bIndex > -1) {
const ret = aIndex - bIndex;
if (ret === 0) {
return a.title.localeCompare(b.title);
}
return ret;
} else if (aIndex > -1) {
return -1;
} else if (bIndex > -1) {
return 1;
}
return a.title.localeCompare(b.title);
});
}
// See if we're filtering by colors, and match against the built-in keywords and actuial hex
// values.
let filterColors = (
(this.properties?.[PROPERTY_MATCH_COLORS] as string)?.split(",") || []
).filter((c) => c.trim());
if (filterColors.length) {
filterColors = filterColors.map((color) => {
color = color.trim().toLocaleLowerCase();
if (LGraphCanvas.node_colors[color]) {
color = LGraphCanvas.node_colors[color]!.groupcolor;
}
color = color.replace("#", "").toLocaleLowerCase();
if (color.length === 3) {
color = color.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
}
return `#${color}`;
});
}
// Go over the groups
let index = 0;
for (const group of groups) {
if (filterColors.length) {
let groupColor = group.color?.replace("#", "").trim().toLocaleLowerCase();
if (!groupColor) {
continue;
}
if (groupColor.length === 3) {
groupColor = groupColor.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
}
groupColor = `#${groupColor}`;
if (!filterColors.includes(groupColor)) {
continue;
}
}
if (this.properties?.[PROPERTY_MATCH_TITLE]?.trim()) {
try {
if (!new RegExp(this.properties[PROPERTY_MATCH_TITLE], "i").exec(group.title)) {
continue;
}
} catch (e) {
console.error(e);
continue;
}
}
const showAllGraphs = this.properties?.[PROPERTY_SHOW_ALL_GRAPHS];
if (!showAllGraphs && group.graph !== app.canvas.getCurrentGraph()) {
continue;
}
let isDirty = false;
const widgetLabel = `Enable ${group.title}`;
let widget = this.widgets.find((w) => w.label === widgetLabel) as FastGroupsToggleRowWidget;
if (!widget) {
// When we add a widget, litegraph is going to mess up the size, so we
// store it so we can retrieve it in computeSize. Hacky..
this.tempSize = [...this.size] as Size;
widget = this.addCustomWidget(
new FastGroupsToggleRowWidget(group, this),
) as FastGroupsToggleRowWidget;
this.setSize(this.computeSize());
isDirty = true;
}
if (widget.label != widgetLabel) {
widget.label = widgetLabel;
isDirty = true;
}
if (
group.rgthree_hasAnyActiveNode != null &&
widget.toggled != group.rgthree_hasAnyActiveNode
) {
widget.toggled = group.rgthree_hasAnyActiveNode;
isDirty = true;
}
if (this.widgets[index] !== widget) {
const oldIndex = this.widgets.findIndex((w) => w === widget);
this.widgets.splice(index, 0, this.widgets.splice(oldIndex, 1)[0]!);
isDirty = true;
}
if (isDirty) {
this.setDirtyCanvas(true, false);
}
index++;
}
// Everything should now be in order, so let's remove all remaining widgets.
while ((this.widgets || [])[index]) {
this.removeWidget(index++);
}
}
override computeSize(out?: Vector2) {
let size = super.computeSize(out);
if (this.tempSize) {
size[0] = Math.max(this.tempSize[0], size[0]);
size[1] = Math.max(this.tempSize[1], size[1]);
// We sometimes get repeated calls to compute size, so debounce before clearing.
this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
this.debouncerTempWidth = setTimeout(() => {
this.tempSize = null;
}, 32);
}
setTimeout(() => {
this.graph?.setDirtyCanvas(true, true);
}, 16);
return size;
}
override async handleAction(action: string) {
if (action === "Mute all" || action === "Bypass all") {
const alwaysOne = this.properties?.[PROPERTY_RESTRICTION] === "always one";
for (const [index, widget] of this.widgets.entries()) {
(widget as any)?.doModeChange(alwaysOne && !index ? true : false, true);
}
} else if (action === "Enable all") {
const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
for (const [index, widget] of this.widgets.entries()) {
(widget as any)?.doModeChange(onlyOne && index > 0 ? false : true, true);
}
} else if (action === "Toggle all") {
const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
let foundOne = false;
for (const [index, widget] of this.widgets.entries()) {
// If you have only one, then we'll stop at the first.
let newValue: boolean = onlyOne && foundOne ? false : !widget.value;
foundOne = foundOne || newValue;
(widget as any)?.doModeChange(newValue, true);
}
// And if you have always one, then we'll flip the last
if (!foundOne && this.properties?.[PROPERTY_RESTRICTION] === "always one") {
(this.widgets[this.widgets.length - 1] as any)?.doModeChange(true, true);
}
}
}
override getHelp() {
return `
<p>The ${this.type!.replace(
"(rgthree)",
"",
)} is an input-less node that automatically collects all groups in your current
workflow and allows you to quickly ${this.helpActions} all nodes within the group.</p>
<ul>
<li>
<p>
<strong>Properties.</strong> You can change the following properties (by right-clicking
on the node, and select "Properties" or "Properties Panel" from the menu):
</p>
<ul>
<li><p>
<code>${PROPERTY_MATCH_COLORS}</code> - Only add groups that match the provided
colors. Can be ComfyUI colors (red, pale_blue) or hex codes (#a4d399). Multiple can be
added, comma delimited.
</p></li>
<li><p>
<code>${PROPERTY_MATCH_TITLE}</code> - Filter the list of toggles by title match
(string match, or regular expression).
</p></li>
<li><p>
<code>${PROPERTY_SHOW_NAV}</code> - Add / remove a quick navigation arrow to take you
to the group. <i>(default: true)</i>
</p></li>
<li><p>
<code>${PROPERTY_SHOW_ALL_GRAPHS}</code> - Show groups from all [sub]graphs in the
workflow. <i>(default: true)</i>
</p></li>
<li><p>
<code>${PROPERTY_SORT}</code> - Sort the toggles' order by "alphanumeric", graph
"position", or "custom alphabet". <i>(default: "position")</i>
</p></li>
<li>
<p>
<code>${PROPERTY_SORT_CUSTOM_ALPHA}</code> - When the
<code>${PROPERTY_SORT}</code> property is "custom alphabet" you can define the
alphabet to use here, which will match the <i>beginning</i> of each group name and
sort against it. If group titles do not match any custom alphabet entry, then they
will be put after groups that do, ordered alphanumerically.
</p>
<p>
This can be a list of single characters, like "zyxw..." or comma delimited strings
for more control, like "sdxl,pro,sd,n,p".
</p>
<p>
Note, when two group title match the same custom alphabet entry, the <i>normal
alphanumeric alphabet</i> breaks the tie. For instance, a custom alphabet of
"e,s,d" will order groups names like "SDXL, SEGS, Detailer" eventhough the custom
alphabet has an "e" before "d" (where one may expect "SE" to be before "SD").
</p>
<p>
To have "SEGS" appear before "SDXL" you can use longer strings. For instance, the
custom alphabet value of "se,s,f" would work here.
</p>
</li>
<li><p>
<code>${PROPERTY_RESTRICTION}</code> - Optionally, attempt to restrict the number of
widgets that can be enabled to a maximum of one, or always one.
</p>
<p><em><strong>Note:</strong> If using "max one" or "always one" then this is only
enforced when clicking a toggle on this node; if nodes within groups are changed
outside of the initial toggle click, then these restriction will not be enforced, and
could result in a state where more than one toggle is enabled. This could also happen
if nodes are overlapped with multiple groups.
</p></li>
</ul>
</li>
</ul>`;
}
}
/**
* Fast Bypasser implementation that looks for groups in the workflow and adds toggles to mute them.
*/
export class FastGroupsMuter extends BaseFastGroupsModeChanger {
static override type = NodeTypesString.FAST_GROUPS_MUTER;
static override title = NodeTypesString.FAST_GROUPS_MUTER;
override comfyClass = NodeTypesString.FAST_GROUPS_MUTER;
static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
protected override helpActions = "mute and unmute";
override readonly modeOn: number = LiteGraph.ALWAYS;
override readonly modeOff: number = LiteGraph.NEVER;
constructor(title = FastGroupsMuter.title) {
super(title);
this.onConstructed();
}
}
/**
* The PowerLoraLoaderHeaderWidget that renders a toggle all switch, as well as some title info
* (more necessary for the double model & clip strengths to label them).
*/
class FastGroupsToggleRowWidget extends RgthreeBaseWidget<{toggled: boolean}> {
override value = {toggled: false};
override options = {on: "yes", off: "no"};
override readonly type = "custom";
label: string = "";
group: LGraphGroup;
node: BaseFastGroupsModeChanger;
constructor(group: LGraphGroup, node: BaseFastGroupsModeChanger) {
super("RGTHREE_TOGGLE_AND_NAV");
this.group = group;
this.node = node;
}
doModeChange(force?: boolean, skipOtherNodeCheck?: boolean) {
this.group.recomputeInsideNodes();
const hasAnyActiveNodes = getGroupNodes(this.group).some((n) => n.mode === LiteGraph.ALWAYS);
let newValue = force != null ? force : !hasAnyActiveNodes;
if (skipOtherNodeCheck !== true) {
// TODO: This work should probably live in BaseFastGroupsModeChanger instead of the widgets.
if (newValue && this.node.properties?.[PROPERTY_RESTRICTION]?.includes(" one")) {
for (const widget of this.node.widgets) {
if ("doModeChange" in widget && typeof widget.doModeChange === "function") {
widget.doModeChange(false, true);
}
}
} else if (!newValue && this.node.properties?.[PROPERTY_RESTRICTION] === "always one") {
newValue = this.node.widgets.every((w) => w === this || !("toggled" in w && w.toggled));
}
}
changeModeOfNodes(getGroupNodes(this.group), (newValue ? this.node.modeOn : this.node.modeOff));
this.group.rgthree_hasAnyActiveNode = newValue;
this.toggled = newValue;
this.group.graph?.setDirtyCanvas(true, false);
}
get toggled() {
return this.value.toggled;
}
set toggled(value: boolean) {
this.value.toggled = value;
}
toggle(value?: boolean) {
value = value == null ? !this.toggled : value;
if (value !== this.toggled) {
this.value.toggled = value;
this.doModeChange();
}
}
draw(
ctx: CanvasRenderingContext2D,
node: FastGroupsMuter,
width: number,
posY: number,
height: number,
) {
const widgetData = drawNodeWidget(ctx, {size: [width, height], pos: [15, posY]});
const showNav = node.properties?.[PROPERTY_SHOW_NAV] !== false;
// Render from right to left, since the text on left will take available space.
// `currentX` markes the current x position moving backwards.
let currentX = widgetData.width - widgetData.margin;
// The nav arrow
if (!widgetData.lowQuality && showNav) {
currentX -= 7; // Arrow space margin
const midY = widgetData.posY + widgetData.height * 0.5;
ctx.fillStyle = ctx.strokeStyle = "#89A";
ctx.lineJoin = "round";
ctx.lineCap = "round";
const arrow = new Path2D(`M${currentX} ${midY} l -7 6 v -3 h -7 v -6 h 7 v -3 z`);
ctx.fill(arrow);
ctx.stroke(arrow);
currentX -= 14;
currentX -= 7;
ctx.strokeStyle = widgetData.colorOutline;
ctx.stroke(new Path2D(`M ${currentX} ${widgetData.posY} v ${widgetData.height}`));
} else if (widgetData.lowQuality && showNav) {
currentX -= 28;
}
// The toggle itself.
currentX -= 7;
ctx.fillStyle = this.toggled ? "#89A" : "#333";
ctx.beginPath();
const toggleRadius = height * 0.36;
ctx.arc(currentX - toggleRadius, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
ctx.fill();
currentX -= toggleRadius * 2;
if (!widgetData.lowQuality) {
currentX -= 4;
ctx.textAlign = "right";
ctx.fillStyle = this.toggled ? widgetData.colorText : widgetData.colorTextSecondary;
const label = this.label;
const toggleLabelOn = this.options.on || "true";
const toggleLabelOff = this.options.off || "false";
ctx.fillText(this.toggled ? toggleLabelOn : toggleLabelOff, currentX, posY + height * 0.7);
currentX -= Math.max(
ctx.measureText(toggleLabelOn).width,
ctx.measureText(toggleLabelOff).width,
);
currentX -= 7;
ctx.textAlign = "left";
let maxLabelWidth = widgetData.width - widgetData.margin - 10 - (widgetData.width - currentX);
if (label != null) {
ctx.fillText(
fitString(ctx, label, maxLabelWidth),
widgetData.margin + 10,
posY + height * 0.7,
);
}
}
}
override serializeValue(node: LGraphNode, index: number) {
return this.value;
}
override mouse(event: CanvasMouseEvent, pos: Vector2, node: LGraphNode): boolean {
if (event.type == "pointerdown") {
if (node.properties?.[PROPERTY_SHOW_NAV] !== false && pos[0] >= node.size[0] - 15 - 28 - 1) {
const canvas = app.canvas as TLGraphCanvas;
const lowQuality = (canvas.ds?.scale || 1) <= 0.5;
if (!lowQuality) {
// Clicked on right half with nav arrow, go to the group, center on group and set
// zoom to see it all.
canvas.centerOnNode(this.group);
const zoomCurrent = canvas.ds?.scale || 1;
const zoomX = canvas.canvas.width / this.group._size[0] - 0.02;
const zoomY = canvas.canvas.height / this.group._size[1] - 0.02;
canvas.setZoom(Math.min(zoomCurrent, zoomX, zoomY), [
canvas.canvas.width / 2,
canvas.canvas.height / 2,
]);
canvas.setDirty(true, true);
}
} else {
this.toggle();
}
}
return true;
}
}
app.registerExtension({
name: "rgthree.FastGroupsMuter",
registerCustomNodes() {
FastGroupsMuter.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == FastGroupsMuter.title) {
(node as FastGroupsMuter).tempSize = [...node.size] as Point;
}
},
});
@@ -0,0 +1,305 @@
import type {
LGraphCanvas as TLGraphCanvas,
LGraphGroup as TLGraphGroup,
LGraph as TLGraph,
Vector2,
CanvasMouseEvent,
} from "@comfyorg/frontend";
import type {AdjustedMouseCustomEvent} from "typings/rgthree.js";
import {app} from "scripts/app.js";
import {rgthree} from "./rgthree.js";
import {changeModeOfNodes, getGroupNodes, getOutputNodes} from "./utils.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
const BTN_SIZE = 20;
const BTN_MARGIN: Vector2 = [6, 6];
const BTN_SPACING = 8;
const BTN_GRID = BTN_SIZE / 8;
const TOGGLE_TO_MODE = new Map([
["MUTE", LiteGraph.NEVER],
["BYPASS", 4],
]);
function getToggles() {
return [...CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.toggles", [])].reverse();
}
/**
* Determines if the user clicked on an fast header icon.
*/
function clickedOnToggleButton(e: CanvasMouseEvent, group: TLGraphGroup): string | null {
const toggles = getToggles();
const pos = group.pos;
const size = group.size;
for (let i = 0; i < toggles.length; i++) {
const toggle = toggles[i];
if (
LiteGraph.isInsideRectangle(
e.canvasX,
e.canvasY,
pos[0] + size[0] - (BTN_SIZE + BTN_MARGIN[0]) * (i + 1),
pos[1] + BTN_MARGIN[1],
BTN_SIZE,
BTN_SIZE,
)
) {
return toggle;
}
}
return null;
}
/**
* Registers the GroupHeaderToggles which places a mute and/or bypass icons in groups headers for
* quick, single-click ability to mute/bypass.
*/
app.registerExtension({
name: "rgthree.GroupHeaderToggles",
async setup() {
/**
* LiteGraph won't call `drawGroups` unless the canvas is dirty. Other nodes will do this, but
* in small workflows, we'll want to trigger it dirty so we can be drawn if we're in hover mode.
*/
setInterval(() => {
if (
CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled") &&
CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.show") !== "always"
) {
app.canvas.setDirty(true, true);
}
}, 250);
/**
* Handles a click on the icon area if the user has the extension enable from settings.
* Hooks into the already overriden mouse down processor from rgthree.
*/
rgthree.addEventListener("on-process-mouse-down", ((e: AdjustedMouseCustomEvent) => {
if (!CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled")) return;
const canvas = app.canvas as TLGraphCanvas;
if (canvas.selected_group) {
const originalEvent = e.detail.originalEvent;
const group = canvas.selected_group;
const clickedOnToggle = clickedOnToggleButton(originalEvent, group) || "";
const toggleAction = clickedOnToggle?.toLocaleUpperCase();
if (toggleAction) {
console.log(toggleAction);
const nodes = getGroupNodes(group);
if (toggleAction === "QUEUE") {
const outputNodes = getOutputNodes(nodes);
if (!outputNodes?.length) {
rgthree.showMessage({
id: "no-output-in-group",
type: "warn",
timeout: 4000,
message: "No output nodes for group!",
});
} else {
rgthree.queueOutputNodes(outputNodes);
}
} else {
const toggleMode = TOGGLE_TO_MODE.get(toggleAction);
if (toggleMode) {
group.recomputeInsideNodes();
const hasAnyActiveNodes = nodes.some((n) => n.mode === LiteGraph.ALWAYS);
const isAllMuted =
!hasAnyActiveNodes && nodes.every((n) => n.mode === LiteGraph.NEVER);
const isAllBypassed =
!hasAnyActiveNodes && !isAllMuted && nodes.every((n) => n.mode === 4);
let newMode: 0 | 1 | 2 | 3 | 4 = LiteGraph.ALWAYS;
if (toggleMode === LiteGraph.NEVER) {
newMode = isAllMuted ? LiteGraph.ALWAYS : LiteGraph.NEVER;
} else {
newMode = isAllBypassed ? LiteGraph.ALWAYS : 4;
}
changeModeOfNodes(nodes, newMode);
}
}
// Make it such that we're not then moving the group on drag.
canvas.selected_group = null;
canvas.dragging_canvas = false;
}
}
}) as EventListener);
/**
* Overrides LiteGraph's Canvas method for drawingGroups and, after calling the original, checks
* that the user has enabled fast toggles and draws them on the top-right of the app..
*/
const drawGroups = LGraphCanvas.prototype.drawGroups;
LGraphCanvas.prototype.drawGroups = function (
canvasEl: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
) {
drawGroups.apply(this, [...arguments] as any);
if (
!CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.enabled") ||
!rgthree.lastCanvasMouseEvent
) {
return;
}
const graph = app.canvas.graph as TLGraph;
let groups: TLGraphGroup[];
// Default to hover if not always.
if (CONFIG_SERVICE.getFeatureValue("group_header_fast_toggle.show") !== "always") {
const hoverGroup = graph.getGroupOnPos(
rgthree.lastCanvasMouseEvent.canvasX,
rgthree.lastCanvasMouseEvent.canvasY,
);
groups = hoverGroup ? [hoverGroup] : [];
} else {
groups = graph._groups || [];
}
if (!groups.length) {
return;
}
const toggles = getToggles();
ctx.save();
for (const group of groups || []) {
const nodes = getGroupNodes(group);
let anyActive = false;
let allMuted = !!nodes.length;
let allBypassed = allMuted;
// Find the current state of the group's nodes.
for (const node of nodes) {
if (!(node instanceof LGraphNode)) continue;
anyActive = anyActive || node.mode === LiteGraph.ALWAYS;
allMuted = allMuted && node.mode === LiteGraph.NEVER;
allBypassed = allBypassed && node.mode === 4;
if (anyActive || (!allMuted && !allBypassed)) {
break;
}
}
// Display each toggle.
for (let i = 0; i < toggles.length; i++) {
const toggle = toggles[i];
const pos = group._pos;
const size = group._size;
ctx.fillStyle = ctx.strokeStyle = group.color || "#335";
const x = pos[0] + size[0] - BTN_MARGIN[0] - BTN_SIZE - (BTN_SPACING + BTN_SIZE) * i;
const y = pos[1] + BTN_MARGIN[1];
const midX = x + BTN_SIZE / 2;
const midY = y + BTN_SIZE / 2;
if (toggle === "queue") {
const outputNodes = getOutputNodes(nodes);
const oldGlobalAlpha = ctx.globalAlpha;
if (!outputNodes?.length) {
ctx.globalAlpha = 0.5;
}
ctx.lineJoin = "round";
ctx.lineCap = "round";
const arrowSizeX = BTN_SIZE * 0.6;
const arrowSizeY = BTN_SIZE * 0.7;
const arrow = new Path2D(
`M ${x + arrowSizeX / 2} ${midY} l 0 -${arrowSizeY / 2} l ${arrowSizeX} ${arrowSizeY / 2} l -${arrowSizeX} ${arrowSizeY / 2} z`,
);
ctx.stroke(arrow);
if (outputNodes?.length) {
ctx.fill(arrow);
}
ctx.globalAlpha = oldGlobalAlpha;
} else {
const on = toggle === "bypass" ? allBypassed : allMuted;
ctx.beginPath();
ctx.lineJoin = "round";
ctx.rect(x, y, BTN_SIZE, BTN_SIZE);
ctx.lineWidth = 2;
if (toggle === "mute") {
ctx.lineJoin = "round";
ctx.lineCap = "round";
if (on) {
ctx.stroke(
new Path2D(`
${eyeFrame(midX, midY)}
${eyeLashes(midX, midY)}
`),
);
} else {
const radius = BTN_GRID * 1.5;
// Eyeball fill
ctx.fill(
new Path2D(`
${eyeFrame(midX, midY)}
${eyeFrame(midX, midY, -1)}
${circlePath(midX, midY, radius)}
${circlePath(midX + BTN_GRID / 2, midY - BTN_GRID / 2, BTN_GRID * 0.375)}
`),
"evenodd",
);
// Eye Outline Stroke
ctx.stroke(new Path2D(`${eyeFrame(midX, midY)} ${eyeFrame(midX, midY, -1)}`));
// Eye lashes (faded)
ctx.globalAlpha = this.editor_alpha * 0.5;
ctx.stroke(new Path2D(`${eyeLashes(midX, midY)} ${eyeLashes(midX, midY, -1)}`));
ctx.globalAlpha = this.editor_alpha;
}
} else {
const lineChanges = on
? `a ${BTN_GRID * 3}, ${BTN_GRID * 3} 0 1, 1 ${BTN_GRID * 3 * 2},0
l ${BTN_GRID * 2.0} 0`
: `l ${BTN_GRID * 8} 0`;
ctx.stroke(
new Path2D(`
M ${x} ${midY}
${lineChanges}
M ${x + BTN_SIZE} ${midY} l -2 2
M ${x + BTN_SIZE} ${midY} l -2 -2
`),
);
ctx.fill(new Path2D(`${circlePath(x + BTN_GRID * 3, midY, BTN_GRID * 1.8)}`));
}
}
}
}
ctx.restore();
};
},
});
function eyeFrame(midX: number, midY: number, yFlip = 1) {
return `
M ${midX - BTN_SIZE / 2} ${midY}
c ${BTN_GRID * 1.5} ${yFlip * BTN_GRID * 2.5}, ${BTN_GRID * (8 - 1.5)} ${
yFlip * BTN_GRID * 2.5
}, ${BTN_GRID * 8} 0
`;
}
function eyeLashes(midX: number, midY: number, yFlip = 1) {
return `
M ${midX - BTN_GRID * 3.46} ${midY + yFlip * BTN_GRID * 0.9} l -1.15 ${1.25 * yFlip}
M ${midX - BTN_GRID * 2.38} ${midY + yFlip * BTN_GRID * 1.6} l -0.90 ${1.5 * yFlip}
M ${midX - BTN_GRID * 1.15} ${midY + yFlip * BTN_GRID * 1.95} l -0.50 ${1.75 * yFlip}
M ${midX + BTN_GRID * 0.0} ${midY + yFlip * BTN_GRID * 2.0} l 0.00 ${2.0 * yFlip}
M ${midX + BTN_GRID * 1.15} ${midY + yFlip * BTN_GRID * 1.95} l 0.50 ${1.75 * yFlip}
M ${midX + BTN_GRID * 2.38} ${midY + yFlip * BTN_GRID * 1.6} l 0.90 ${1.5 * yFlip}
M ${midX + BTN_GRID * 3.46} ${midY + yFlip * BTN_GRID * 0.9} l 1.15 ${1.25 * yFlip}
`;
}
function circlePath(cx: number, cy: number, radius: number) {
return `
M ${cx} ${cy}
m ${radius}, 0
a ${radius},${radius} 0 1, 1 -${radius * 2},0
a ${radius},${radius} 0 1, 1 ${radius * 2},0
`;
}
@@ -0,0 +1,84 @@
import type {ComfyNodeDef, LGraphNode} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {tryToGetWorkflowDataFromEvent} from "rgthree/common/utils_workflow.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
import {NodeTypesString} from "./constants.js";
/**
* Registers the GroupHeaderToggles which places a mute and/or bypass icons in groups headers for
* quick, single-click ability to mute/bypass.
*/
app.registerExtension({
name: "rgthree.ImportIndividualNodes",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
const onDragOver = nodeType.prototype.onDragOver;
nodeType.prototype.onDragOver = function (e: DragEvent) {
let handled = onDragOver?.apply?.(this, [...arguments] as any);
if (handled != null) {
return handled;
}
return importIndividualNodesInnerOnDragOver(this, e);
};
const onDragDrop = nodeType.prototype.onDragDrop;
nodeType.prototype.onDragDrop = async function (e: DragEvent) {
const alreadyHandled = await onDragDrop?.apply?.(this, [...arguments] as any);
if (alreadyHandled) {
return alreadyHandled;
}
return importIndividualNodesInnerOnDragDrop(this, e);
};
},
});
export function importIndividualNodesInnerOnDragOver(node: LGraphNode, e: DragEvent): boolean {
return (
(node.widgets?.length && !!CONFIG_SERVICE.getFeatureValue("import_individual_nodes.enabled")) ||
false
);
}
export async function importIndividualNodesInnerOnDragDrop(node: LGraphNode, e: DragEvent) {
if (!node.widgets?.length || !CONFIG_SERVICE.getFeatureValue("import_individual_nodes.enabled")) {
return false;
}
const dynamicWidgetLengthNodes = [NodeTypesString.POWER_LORA_LOADER];
let handled = false;
const {workflow, prompt} = await tryToGetWorkflowDataFromEvent(e);
const exact = (workflow?.nodes || []).find(
(n: any) =>
n.id == node.id && // Loose equality check to capture both number and strings.
n.type === node.type &&
(dynamicWidgetLengthNodes.includes(node.type) ||
n.widgets_values?.length === node.widgets_values?.length),
);
if (!exact) {
// If we tried, but didn't find an exact match, then allow user to stop the default behavior.
handled = !confirm(
"[rgthree-comfy] Could not find a matching node (same id & type) in the dropped workflow." +
" Would you like to continue with the default drop behaviour instead?",
);
} else if (!exact.widgets_values?.length) {
handled = !confirm(
"[rgthree-comfy] Matching node found (same id & type) but there's no widgets to set." +
" Would you like to continue with the default drop behaviour instead?",
);
} else if (
confirm(
"[rgthree-comfy] Found a matching node (same id & type) in the dropped workflow." +
" Would you like to set the widget values?",
)
) {
node.configure({
// Title is overridden if it's not supplied; set it to the current then.
title: node.title,
widgets_values: [...(exact?.widgets_values || [])],
mode: exact.mode,
} as any);
handled = true;
}
return handled;
}
+479
View File
@@ -0,0 +1,479 @@
import {
ComfyNodeDef,
LGraphCanvas,
LGraphNode,
Vector2,
CanvasMouseEvent,
ISerialisedNode,
Point,
CanvasPointerEvent,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {api} from "scripts/api.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {addConnectionLayoutSupport} from "./utils.js";
import {RgthreeBaseHitAreas, RgthreeBaseWidget, RgthreeBaseWidgetBounds} from "./utils_widgets.js";
import {measureText} from "./utils_canvas.js";
type ComfyImageServerData = {filename: string; type: string; subfolder: string};
type ComfyImageData = {name: string; selected: boolean; url: string; img?: HTMLImageElement};
type OldExecutedPayload = {
images: ComfyImageServerData[];
};
type ExecutedPayload = {
a_images?: ComfyImageServerData[];
b_images?: ComfyImageServerData[];
};
function imageDataToUrl(data: ComfyImageServerData) {
return api.apiURL(
`/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}&subfolder=${
data.subfolder
}${app.getPreviewFormatParam()}${app.getRandParam()}`,
);
}
/**
* Compares two images in one canvas node.
*/
export class RgthreeImageComparer extends RgthreeBaseServerNode {
static override title = NodeTypesString.IMAGE_COMPARER;
static override type = NodeTypesString.IMAGE_COMPARER;
static comfyClass = NodeTypesString.IMAGE_COMPARER;
// These is what the core preview image node uses to show the context menu. May not be that helpful
// since it likely will always be "0" when a context menu is invoked without manually changing
// something.
override imageIndex: number = 0;
override imgs: InstanceType<typeof Image>[] = [];
override serialize_widgets = true;
isPointerDown = false;
isPointerOver = false;
pointerOverPos: Vector2 = [0, 0];
private canvasWidget: RgthreeImageComparerWidget | null = null;
static "@comparer_mode" = {
type: "combo",
values: ["Slide", "Click"],
};
constructor(title = RgthreeImageComparer.title) {
super(title);
this.properties["comparer_mode"] = "Slide";
}
override onExecuted(output: ExecutedPayload | OldExecutedPayload) {
super.onExecuted?.(output);
if ("images" in output) {
this.canvasWidget!.value = {
images: (output.images || []).map((d, i) => {
return {
name: i === 0 ? "A" : "B",
selected: true,
url: imageDataToUrl(d),
};
}),
};
} else {
output.a_images = output.a_images || [];
output.b_images = output.b_images || [];
const imagesToChoose: ComfyImageData[] = [];
const multiple = output.a_images.length + output.b_images.length > 2;
for (const [i, d] of output.a_images.entries()) {
imagesToChoose.push({
name: output.a_images.length > 1 || multiple ? `A${i + 1}` : "A",
selected: i === 0,
url: imageDataToUrl(d),
});
}
for (const [i, d] of output.b_images.entries()) {
imagesToChoose.push({
name: output.b_images.length > 1 || multiple ? `B${i + 1}` : "B",
selected: i === 0,
url: imageDataToUrl(d),
});
}
this.canvasWidget!.value = {images: imagesToChoose};
}
}
override onSerialize(serialised: ISerialisedNode) {
super.onSerialize && super.onSerialize(serialised);
for (let [index, widget_value] of (serialised.widgets_values || []).entries()) {
if (this.widgets[index]?.name === "rgthree_comparer") {
serialised.widgets_values![index] = (
this.widgets[index] as unknown as RgthreeImageComparerWidget
).value.images.map((d) => {
d = {...d};
delete d.img;
return d;
});
}
}
}
override onNodeCreated() {
this.canvasWidget = this.addCustomWidget(
new RgthreeImageComparerWidget("rgthree_comparer", this),
) as RgthreeImageComparerWidget;
this.setSize(this.computeSize());
this.setDirtyCanvas(true, true);
}
/**
* Sets mouse as down or up based on param. If it's down, we also loop to check pointer is still
* down. This is because LiteGraph doesn't fire `onMouseUp` every time there's a mouse up, so we
* need to manually monitor `pointer_is_down` and, when it's no longer true, set mouse as up here.
*/
private setIsPointerDown(down: boolean = this.isPointerDown) {
const newIsDown = down && !!app.canvas.pointer_is_down;
if (this.isPointerDown !== newIsDown) {
this.isPointerDown = newIsDown;
this.setDirtyCanvas(true, false);
}
this.imageIndex = this.isPointerDown ? 1 : 0;
if (this.isPointerDown) {
requestAnimationFrame(() => {
this.setIsPointerDown();
});
}
}
override onMouseDown(event: CanvasPointerEvent, pos: Point, canvas: LGraphCanvas): boolean {
super.onMouseDown?.(event, pos, canvas);
this.setIsPointerDown(true);
return false;
}
override onMouseEnter(event: CanvasPointerEvent): void {
super.onMouseEnter?.(event);
this.setIsPointerDown(!!app.canvas.pointer_is_down);
this.isPointerOver = true;
}
override onMouseLeave(event: CanvasPointerEvent): void {
super.onMouseLeave?.(event);
this.setIsPointerDown(false);
this.isPointerOver = false;
}
override onMouseMove(event: CanvasPointerEvent, pos: Point, canvas: LGraphCanvas): void {
super.onMouseMove?.(event, pos, canvas);
this.pointerOverPos = [...pos] as Point;
this.imageIndex = this.pointerOverPos[0] > this.size[0] / 2 ? 1 : 0;
}
override getHelp(): string {
return `
<p>
The ${this.type!.replace("(rgthree)", "")} node compares two images on top of each other.
</p>
<ul>
<li>
<p>
<strong>Notes</strong>
</p>
<ul>
<li><p>
The right-click menu may show image options (Open Image, Save Image, etc.) which will
correspond to the first image (image_a) if clicked on the left-half of the node, or
the second image if on the right half of the node.
</p></li>
</ul>
</li>
<li>
<p>
<strong>Inputs</strong>
</p>
<ul>
<li><p>
<code>image_a</code> <i>Optional.</i> The first image to use to compare.
image_a.
</p></li>
<li><p>
<code>image_b</code> <i>Optional.</i> The second image to use to compare.
</p></li>
<li><p>
<b>Note</b> <code>image_a</code> and <code>image_b</code> work best when a single
image is provided. However, if each/either are a batch, you can choose which item
from each batch are chosen to be compared. If either <code>image_a</code> or
<code>image_b</code> are not provided, the node will choose the first two from the
provided input if it's a batch, otherwise only show the single image (just as
Preview Image would).
</p></li>
</ul>
</li>
<li>
<p>
<strong>Properties.</strong> You can change the following properties (by right-clicking
on the node, and select "Properties" or "Properties Panel" from the menu):
</p>
<ul>
<li><p>
<code>comparer_mode</code> - Choose between "Slide" and "Click". Defaults to "Slide".
</p></li>
</ul>
</li>
</ul>`;
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, RgthreeImageComparer);
}
static override onRegisteredForOverride(comfyClass: any) {
addConnectionLayoutSupport(RgthreeImageComparer, app, [
["Left", "Right"],
["Right", "Left"],
]);
setTimeout(() => {
RgthreeImageComparer.category = comfyClass.category;
});
}
}
type RgthreeImageComparerWidgetValue = {
images: ComfyImageData[];
};
class RgthreeImageComparerWidget extends RgthreeBaseWidget<RgthreeImageComparerWidgetValue> {
override readonly type = "custom";
private node: RgthreeImageComparer;
protected override hitAreas: RgthreeBaseHitAreas<any> = {
// We dynamically set this when/if we draw the labels.
};
private selected: [ComfyImageData?, ComfyImageData?] = [];
constructor(name: string, node: RgthreeImageComparer) {
super(name);
this.node = node;
}
private _value: RgthreeImageComparerWidgetValue = {images: []};
set value(v: RgthreeImageComparerWidgetValue) {
// Despite `v` typed as RgthreeImageComparerWidgetValue, we may have gotten an array of strings
// from previous versions. We can handle that gracefully.
let cleanedVal;
if (Array.isArray(v)) {
cleanedVal = v.map((d, i) => {
if (!d || typeof d === "string") {
// We usually only have two here, so they're selected.
d = {url: d, name: i == 0 ? "A" : "B", selected: true};
}
return d;
});
} else {
cleanedVal = v.images || [];
}
// If we have multiple items in our sent value but we don't have both an "A" and a "B" then
// just simplify it down to the first two in the list.
if (cleanedVal.length > 2) {
const hasAAndB =
cleanedVal.some((i) => i.name.startsWith("A")) &&
cleanedVal.some((i) => i.name.startsWith("B"));
if (!hasAAndB) {
cleanedVal = [cleanedVal[0], cleanedVal[1]];
}
}
let selected = cleanedVal.filter((d) => d.selected);
// None are selected.
if (!selected.length && cleanedVal.length) {
cleanedVal[0]!.selected = true;
}
selected = cleanedVal.filter((d) => d.selected);
if (selected.length === 1 && cleanedVal.length > 1) {
cleanedVal.find((d) => !d.selected)!.selected = true;
}
this._value.images = cleanedVal;
selected = cleanedVal.filter((d) => d.selected);
this.setSelected(selected as [ComfyImageData, ComfyImageData]);
}
get value() {
return this._value;
}
setSelected(selected: [ComfyImageData, ComfyImageData]) {
this._value.images.forEach((d) => (d.selected = false));
this.node.imgs.length = 0;
for (const sel of selected) {
if (!sel.img) {
sel.img = new Image();
sel.img.src = sel.url;
this.node.imgs.push(sel.img);
}
sel.selected = true;
}
this.selected = selected;
}
draw(ctx: CanvasRenderingContext2D, node: RgthreeImageComparer, width: number, y: number) {
this.hitAreas = {};
if (this.value.images.length > 2) {
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.font = `14px Arial`;
// Let's calculate the widths of all the labels.
const drawData: any = [];
const spacing = 5;
let x = 0;
for (const img of this.value.images) {
const width = measureText(ctx, img.name);
drawData.push({
img,
text: img.name,
x,
width: measureText(ctx, img.name),
});
x += width + spacing;
}
x = (node.size[0] - (x - spacing)) / 2;
for (const d of drawData) {
ctx.fillStyle = d.img.selected ? "rgba(180, 180, 180, 1)" : "rgba(180, 180, 180, 0.5)";
ctx.fillText(d.text, x, y);
this.hitAreas[d.text] = {
bounds: [x, y, d.width, 14],
data: d.img,
onDown: this.onSelectionDown,
};
x += d.width + spacing;
}
y += 20;
}
if (node.properties?.["comparer_mode"] === "Click") {
this.drawImage(ctx, this.selected[this.node.isPointerDown ? 1 : 0], y);
} else {
this.drawImage(ctx, this.selected[0], y);
if (node.isPointerOver) {
this.drawImage(ctx, this.selected[1], y, this.node.pointerOverPos[0]);
}
}
}
private onSelectionDown(
event: CanvasMouseEvent,
pos: Vector2,
node: LGraphNode,
bounds?: RgthreeBaseWidgetBounds,
) {
const selected = [...this.selected];
if (bounds?.data.name.startsWith("A")) {
selected[0] = bounds.data;
} else if (bounds?.data.name.startsWith("B")) {
selected[1] = bounds.data;
}
this.setSelected(selected as [ComfyImageData, ComfyImageData]);
}
private drawImage(
ctx: CanvasRenderingContext2D,
image: ComfyImageData | undefined,
y: number,
cropX?: number,
) {
if (!image?.img?.naturalWidth || !image?.img?.naturalHeight) {
return;
}
let [nodeWidth, nodeHeight] = this.node.size as [number, number];
const imageAspect = image?.img.naturalWidth / image?.img.naturalHeight;
let height = nodeHeight - y;
const widgetAspect = nodeWidth / height;
let targetWidth, targetHeight;
let offsetX = 0;
if (imageAspect > widgetAspect) {
targetWidth = nodeWidth;
targetHeight = nodeWidth / imageAspect;
} else {
targetHeight = height;
targetWidth = height * imageAspect;
offsetX = (nodeWidth - targetWidth) / 2;
}
const widthMultiplier = image?.img.naturalWidth / targetWidth;
const sourceX = 0;
const sourceY = 0;
const sourceWidth =
cropX != null ? (cropX - offsetX) * widthMultiplier : image?.img.naturalWidth;
const sourceHeight = image?.img.naturalHeight;
const destX = (nodeWidth - targetWidth) / 2;
const destY = y + (height - targetHeight) / 2;
const destWidth = cropX != null ? cropX - offsetX : targetWidth;
const destHeight = targetHeight;
ctx.save();
ctx.beginPath();
let globalCompositeOperation = ctx.globalCompositeOperation;
if (cropX) {
ctx.rect(destX, destY, destWidth, destHeight);
ctx.clip();
}
ctx.drawImage(
image?.img,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
destX,
destY,
destWidth,
destHeight,
);
// Shows a label overlayed on the image. Not perfect, keeping commented out.
// ctx.globalCompositeOperation = "difference";
// ctx.fillStyle = "rgba(180, 180, 180, 1)";
// ctx.textAlign = "center";
// ctx.font = `32px Arial`;
// ctx.fillText(image.name, nodeWidth / 2, y + 32);
if (cropX != null && cropX >= (nodeWidth - targetWidth) / 2 && cropX <= targetWidth + offsetX) {
ctx.beginPath();
ctx.moveTo(cropX, destY);
ctx.lineTo(cropX, destY + destHeight);
ctx.globalCompositeOperation = "difference";
ctx.strokeStyle = "rgba(255,255,255, 1)";
ctx.stroke();
}
ctx.globalCompositeOperation = globalCompositeOperation;
ctx.restore();
}
computeSize(width: number): Vector2 {
return [width, 20];
}
override serializeValue(
node: LGraphNode,
index: number,
): RgthreeImageComparerWidgetValue | Promise<RgthreeImageComparerWidgetValue> {
const v = [];
for (const data of this._value.images) {
// Remove the img since it can't serialize.
const d = {...data};
delete d.img;
v.push(d);
}
return {images: v};
}
}
app.registerExtension({
name: "rgthree.ImageComparer",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === RgthreeImageComparer.type) {
RgthreeImageComparer.setUp(nodeType, nodeData);
}
},
});
+69
View File
@@ -0,0 +1,69 @@
import type {ComfyNodeDef, LGraph, ISerialisedNode} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
class ImageInsetCrop extends RgthreeBaseServerNode {
static override title = NodeTypesString.IMAGE_INSET_CROP;
static override type = NodeTypesString.IMAGE_INSET_CROP;
static comfyClass = NodeTypesString.IMAGE_INSET_CROP;
static override exposedActions = ["Reset Crop"];
static maxResolution = 8192;
constructor(title = ImageInsetCrop.title) {
super(title);
}
override onAdded(graph: LGraph): void {
const measurementWidget = this.widgets[0]!;
let callback = measurementWidget.callback;
measurementWidget.callback = (...args) => {
this.setWidgetStep();
callback && callback.apply(measurementWidget, [...args]);
};
this.setWidgetStep();
}
override configure(info: ISerialisedNode): void {
super.configure(info);
this.setWidgetStep();
}
private setWidgetStep() {
const measurementWidget = this.widgets[0]!;
for (let i = 1; i <= 4; i++) {
if (measurementWidget.value === "Pixels") {
this.widgets[i]!.options.step = 80;
this.widgets[i]!.options.max = ImageInsetCrop.maxResolution;
} else {
this.widgets[i]!.options.step = 10;
this.widgets[i]!.options.max = 99;
}
}
}
override async handleAction(action: string): Promise<void> {
if (action === "Reset Crop") {
for (const widget of this.widgets) {
if (["left", "right", "top", "bottom"].includes(widget.name!)) {
widget.value = 0;
}
}
}
}
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, ImageInsetCrop);
}
}
app.registerExtension({
name: "rgthree.ImageInsetCrop",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === NodeTypesString.IMAGE_INSET_CROP) {
ImageInsetCrop.setUp(nodeType, nodeData);
}
},
});
+50
View File
@@ -0,0 +1,50 @@
import type {ComfyNodeDef, ISerialisedNode} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseServerNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
class RgthreeImageOrLatentSize extends RgthreeBaseServerNode {
static override title = NodeTypesString.IMAGE_OR_LATENT_SIZE;
static override type = NodeTypesString.IMAGE_OR_LATENT_SIZE;
static comfyClass = NodeTypesString.IMAGE_OR_LATENT_SIZE;
static override setUp(comfyClass: typeof LGraphNode, nodeData: ComfyNodeDef) {
RgthreeBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
}
constructor(title = NODE_CLASS.title) {
super(title);
}
override onNodeCreated() {
super.onNodeCreated?.();
// Litegraph uses an array of acceptable input types, even though ComfyUI's types don't type
// it out that way.
this.addInput("input", ["IMAGE", "LATENT", "MASK"] as any);
}
override configure(info: ISerialisedNode): void {
super.configure(info);
if (this.inputs?.length) {
// Litegraph uses an array of acceptable input types, even though ComfyUI's types don't type
// it out that way.
this.inputs[0]!.type = ["IMAGE", "LATENT", "MASK"] as any;
}
}
}
/** An uniformed name reference to the node class. */
const NODE_CLASS = RgthreeImageOrLatentSize;
/** Register the node. */
app.registerExtension({
name: "rgthree.ImageOrLatentSize",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name === NODE_CLASS.type) {
NODE_CLASS.setUp(nodeType, nodeData);
}
},
});
+229
View File
@@ -0,0 +1,229 @@
import type {
LGraphCanvas as TLGraphCanvas,
LGraphNode,
Vector2,
CanvasMouseEvent,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {rgthree} from "./rgthree.js";
/**
* A label node that allows you to put floating text anywhere on the graph. The text is the `Title`
* and the font size, family, color, alignment as well as a background color, padding, and
* background border radius can all be adjusted in the properties. Multiline text can be added from
* the properties panel (because ComfyUI let's you shift + enter there, only).
*/
export class Label extends RgthreeBaseVirtualNode {
static override type = NodeTypesString.LABEL;
static override title = NodeTypesString.LABEL;
override comfyClass = NodeTypesString.LABEL;
static readonly title_mode = LiteGraph.NO_TITLE;
static collapsable = false;
static "@fontSize" = {type: "number"};
static "@fontFamily" = {type: "string"};
static "@fontColor" = {type: "string"};
static "@textAlign" = {type: "combo", values: ["left", "center", "right"]};
static "@backgroundColor" = {type: "string"};
static "@padding" = {type: "number"};
static "@borderRadius" = {type: "number"};
static "@angle" = {type: "number"};
override properties!: RgthreeBaseVirtualNode["properties"] & {
fontSize: number;
fontFamily: string;
fontColor: string;
textAlign: string;
backgroundColor: string;
padding: number;
borderRadius: number;
angle: number;
};
override resizable = false;
constructor(title = Label.title) {
super(title);
this.properties["fontSize"] = 12;
this.properties["fontFamily"] = "Arial";
this.properties["fontColor"] = "#ffffff";
this.properties["textAlign"] = "left";
this.properties["backgroundColor"] = "transparent";
this.properties["padding"] = 0;
this.properties["borderRadius"] = 0;
this.properties["angle"] = 0;
this.color = "#fff0";
this.bgcolor = "#fff0";
this.onConstructed();
}
draw(ctx: CanvasRenderingContext2D) {
this.flags = this.flags || {};
this.flags.allow_interaction = !this.flags.pinned;
ctx.save();
this.color = "#fff0";
this.bgcolor = "#fff0";
const fontColor = this.properties["fontColor"] || "#ffffff";
const backgroundColor = this.properties["backgroundColor"] || "";
ctx.font = `${Math.max(this.properties["fontSize"] || 0, 1)}px ${
this.properties["fontFamily"] ?? "Arial"
}`;
const padding = Number(this.properties["padding"]) ?? 0;
// Support literal "\\n" sequences as newlines and trim trailing newlines
const processedTitle = (this.title ?? "").replace(/\\n/g, "\n").replace(/\n*$/, "");
const lines = processedTitle.split("\n");
const maxWidth = Math.max(...lines.map((s) => ctx.measureText(s).width));
this.size[0] = maxWidth + padding * 2;
this.size[1] = this.properties["fontSize"] * lines.length + padding * 2;
// Apply rotation around the center, if angle provided
const angleDeg = parseInt(String(this.properties["angle"] ?? 0)) || 0;
if (angleDeg) {
const cx = this.size[0] / 2;
const cy = this.size[1] / 2;
ctx.translate(cx, cy);
ctx.rotate((angleDeg * Math.PI) / 180);
ctx.translate(-cx, -cy);
}
if (backgroundColor) {
ctx.beginPath();
const borderRadius = Number(this.properties["borderRadius"]) || 0;
ctx.roundRect(0, 0, this.size[0], this.size[1], [borderRadius]);
ctx.fillStyle = backgroundColor;
ctx.fill();
}
ctx.textAlign = "left";
let textX = padding;
if (this.properties["textAlign"] === "center") {
ctx.textAlign = "center";
textX = this.size[0] / 2;
} else if (this.properties["textAlign"] === "right") {
ctx.textAlign = "right";
textX = this.size[0] - padding;
}
ctx.textBaseline = "top";
ctx.fillStyle = fontColor;
let currentY = padding;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i] || " ", textX, currentY);
currentY += this.properties["fontSize"];
}
ctx.restore();
}
override onDblClick(event: CanvasMouseEvent, pos: Vector2, canvas: TLGraphCanvas) {
// Since everything we can do here is in the properties, let's pop open the properties panel.
LGraphCanvas.active_canvas.showShowNodePanel(this);
}
override onShowCustomPanelInfo(panel: HTMLElement) {
panel.querySelector('div.property[data-property="Mode"]')?.remove();
panel.querySelector('div.property[data-property="Color"]')?.remove();
}
override inResizeCorner(x: number, y: number) {
// A little ridiculous there's both a resizable property and this method separately to draw the
// resize icon...
return this.resizable;
}
override getHelp() {
return `
<p>
The rgthree-comfy ${this.type!.replace("(rgthree)", "")} node allows you to add a floating
label to your workflow.
</p>
<p>
The text shown is the "Title" of the node and you can adjust the the font size, font family,
font color, text alignment as well as a background color, padding, and background border
radius from the node's properties. You can double-click the node to open the properties
panel.
<p>
<ul>
<li>
<p>
<strong>Pro tip #1:</strong> You can add multiline text from the properties panel
<i>(because ComfyUI let's you shift + enter there, only)</i>.
</p>
</li>
<li>
<p>
<strong>Pro tip #2:</strong> You can use ComfyUI's native "pin" option in the
right-click menu to make the label stick to the workflow and clicks to "go through".
You can right-click at any time to unpin.
</p>
</li>
<li>
<p>
<strong>Pro tip #3:</strong> Color values are hexidecimal strings, like "#FFFFFF" for
white, or "#660000" for dark red. You can supply a 7th & 8th value (or 5th if using
shorthand) to create a transluscent color. For instance, "#FFFFFF88" is semi-transparent
white.
</p>
</li>
</ul>`;
}
}
/**
* We override the drawNode to see if we're drawing our label and, if so, hijack it so we can draw
* it like we want. We also do call out to oldDrawNode, which takes care of very minimal things,
* like a select box.
*/
const oldDrawNode = LGraphCanvas.prototype.drawNode;
LGraphCanvas.prototype.drawNode = function (node: LGraphNode, ctx: CanvasRenderingContext2D) {
if (node.constructor === Label.prototype.constructor) {
// These get set very aggressively; maybe an extension is doing it. We'll just clear them out
// each time.
(node as Label).bgcolor = "transparent";
(node as Label).color = "transparent";
const v = oldDrawNode.apply(this, arguments as any);
(node as Label).draw(ctx);
return v;
}
const v = oldDrawNode.apply(this, arguments as any);
return v;
};
/**
* We override LGraph getNodeOnPos to see if we're being called while also processing a mouse down
* and, if so, filter out any label nodes on labels that are pinned. This makes the click go
* "through" the label. We still allow right clicking (so you can unpin) and double click for the
* properties panel, though that takes two double clicks (one to select, one to actually double
* click).
*/
const oldGetNodeOnPos = LGraph.prototype.getNodeOnPos;
LGraph.prototype.getNodeOnPos = function (x: number, y: number, nodes_list?: LGraphNode[]) {
if (
// processMouseDown always passes in the nodes_list
nodes_list &&
rgthree.processingMouseDown &&
rgthree.lastCanvasMouseEvent?.type.includes("down") &&
rgthree.lastCanvasMouseEvent?.which === 1
) {
// Using the same logic from LGraphCanvas processMouseDown, let's see if we consider this a
// double click.
let isDoubleClick = LiteGraph.getTime() - LGraphCanvas.active_canvas.last_mouseclick < 300;
if (!isDoubleClick) {
nodes_list = [...nodes_list].filter((n) => !(n instanceof Label) || !n.flags?.pinned);
}
}
return oldGetNodeOnPos.apply(this, [x, y, nodes_list]);
};
// Register the extension.
app.registerExtension({
name: "rgthree.Label",
registerCustomNodes() {
Label.setUp();
},
});
+156
View File
@@ -0,0 +1,156 @@
import type {
LGraphNode,
ContextMenu,
IContextMenuOptions,
IContextMenuValue,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {rgthree} from "./rgthree.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
const SPECIAL_ENTRIES = [/^(CHOOSE|NONE|DISABLE|OPEN)(\s|$)/i, /^\p{Extended_Pictographic}/gu];
/**
* Handles a large, flat list of string values given ContextMenu and breaks it up into subfolder, if
* they exist. This is experimental and initially built to work for CheckpointLoaderSimple.
*/
app.registerExtension({
name: "rgthree.ContextMenuAutoNest",
async setup() {
const logger = rgthree.newLogSession("[ContextMenuAutoNest]");
const existingContextMenu = LiteGraph.ContextMenu;
// @ts-ignore: TypeScript doesn't like this override.
LiteGraph.ContextMenu = function (
values: IContextMenuValue[],
options: IContextMenuOptions<string, {rgthree_doNotNest: boolean}>,
) {
const threshold = CONFIG_SERVICE.getConfigValue("features.menu_auto_nest.threshold", 20);
const enabled = CONFIG_SERVICE.getConfigValue("features.menu_auto_nest.subdirs", false);
// If we're not enabled, or are incompatible, then just call out safely.
let incompatible: string | boolean = !enabled || !!options?.extra?.rgthree_doNotNest;
if (!incompatible) {
if (values.length <= threshold) {
incompatible = `Skipping context menu auto nesting b/c threshold is not met (${threshold})`;
}
// If there's a rgthree_originalCallback, then we're nested and don't need to check things
// we only expect on the first nesting.
if (!(options.parentMenu?.options as any)?.rgthree_originalCallback) {
// On first context menu, we require a callback and a flat list of options as strings.
if (!options?.callback) {
incompatible = `Skipping context menu auto nesting b/c a callback was expected.`;
} else if (values.some((i) => typeof i !== "string")) {
incompatible = `Skipping context menu auto nesting b/c not all values were strings.`;
}
}
}
if (incompatible) {
if (enabled) {
const [n, v] = logger.infoParts(
"Skipping context menu auto nesting for incompatible menu.",
);
console[n]?.(...v);
}
return existingContextMenu.apply(this as any, [...arguments] as any);
}
const folders: {[key: string]: IContextMenuValue[]} = {};
const specialOps: IContextMenuValue[] = [];
const folderless: IContextMenuValue[] = [];
for (const value of values) {
if (!value) {
folderless.push(value);
continue;
}
const newValue = typeof value === "string" ? {content: value} : Object.assign({}, value);
(newValue as any).rgthree_originalValue = (value as any).rgthree_originalValue || value;
const valueContent = newValue.content || "";
const splitBy = valueContent.indexOf("/") > -1 ? "/" : "\\";
const valueSplit = valueContent.split(splitBy);
if (valueSplit.length > 1) {
const key = valueSplit.shift()!;
newValue.content = valueSplit.join(splitBy);
folders[key] = folders[key] || [];
folders[key]!.push(newValue);
} else if (SPECIAL_ENTRIES.some((r) => r.test(valueContent))) {
specialOps.push(newValue);
} else {
folderless.push(newValue);
}
}
const foldersCount = Object.values(folders).length;
if (foldersCount > 0) {
// Propogate the original callback down through the options.
(options as any).rgthree_originalCallback =
(options as any).rgthree_originalCallback ||
(options.parentMenu?.options as any)?.rgthree_originalCallback ||
options.callback;
const oldCallback = (options as any)?.rgthree_originalCallback;
options.callback = undefined;
const newCallback = (
item: IContextMenuValue,
options: IContextMenuOptions,
event: MouseEvent,
parentMenu: ContextMenu | undefined,
node: LGraphNode,
) => {
oldCallback?.((item as any)?.rgthree_originalValue!, options, event, undefined, node);
};
const [n, v] = logger.infoParts(`Nested folders found (${foldersCount}).`);
console[n]?.(...v);
const newValues: IContextMenuValue[] = [];
for (const [folderName, folderValues] of Object.entries(folders)) {
newValues.push({
content: `📁 ${folderName}`,
has_submenu: true,
callback: () => {
/* no-op, use the item callback. */
},
submenu: {
options: folderValues.map((value) => {
value!.callback = newCallback;
return value;
}),
},
});
}
values = ([] as IContextMenuValue[]).concat(
specialOps.map((f) => {
if (typeof f === "string") {
f = {content: f};
}
f!.callback = newCallback;
return f;
}),
newValues,
folderless.map((f) => {
if (typeof f === "string") {
f = {content: f};
}
f!.callback = newCallback;
return f;
}),
);
}
if (options.scale == null) {
options.scale = Math.max(app.canvas.ds?.scale || 1, 1);
}
const oldCtrResponse = existingContextMenu.call(this as any, values, options as any);
// For some reason, LiteGraph calls submenus with "this.constructor" which no longer allows
// us to continue building deep nesting, as well as skips many other extensions (even
// ComfyUI's core extensions like translations) from working on submenus. It also removes
// search, etc. While this is a recent-ish issue, I can't seem to find the culpit as it looks
// like old litegraph always did this. Perhaps changing it to a Class? Anyway, this fixes it;
// Hopefully without issues.
if ((oldCtrResponse as any)?.constructor) {
(oldCtrResponse as any).constructor = LiteGraph.ContextMenu;
}
return this;
};
},
});
+74
View File
@@ -0,0 +1,74 @@
import type {IContextMenuValue, LGraphCanvas, ComfyNodeDef} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
const clipboardSupportedPromise = new Promise<boolean>(async (resolve) => {
try {
// MDN says to check this, but it doesn't work in Mozilla... however, in secure contexts
// (localhost included), it's given by default if the user has it flagged.. so we should be
// able to check in the latter ClipboardItem too.
const result = await navigator.permissions.query({name: "clipboard-write"} as any);
resolve(result.state === "granted");
return;
} catch (e) {
try {
if (!navigator.clipboard.write) {
throw new Error();
}
new ClipboardItem({"image/png": new Blob([], {type: "image/png"})});
resolve(true);
return;
} catch (e) {
resolve(false);
}
}
});
/**
* Adds a "Copy Image" to images in similar fashion to the "native" Open Image and Save Image
* options.
*/
app.registerExtension({
name: "rgthree.CopyImageToClipboard",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
if (nodeData.name.toLowerCase().includes("image")) {
if (await clipboardSupportedPromise) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (
canvas: LGraphCanvas,
options: (IContextMenuValue<unknown> | null)[],
): (IContextMenuValue<unknown> | null)[] {
options = getExtraMenuOptions?.call(this, canvas, options) ?? options;
// If we already have a copy image somehow, then let's skip ours.
if (this.imgs?.length) {
const img =
this.imgs[this.imageIndex || 0] || this.imgs[this.overIndex || 0] || this.imgs[0];
const foundIdx = options.findIndex((option) => option?.content?.includes("Copy Image"));
if (img && foundIdx === -1) {
const menuItem: IContextMenuValue = {
content: "Copy Image (rgthree)",
callback: () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight);
canvas.toBlob((blob) => {
navigator.clipboard.write([new ClipboardItem({"image/png": blob!})]);
});
},
};
let idx = options.findIndex((option) => option?.content?.includes("Open Image")) + 1;
if (idx != null) {
options.splice(idx, 0, menuItem);
} else {
options.unshift(menuItem);
}
}
}
return [];
};
}
}
},
});
+93
View File
@@ -0,0 +1,93 @@
import type {
ComfyNodeDef,
IContextMenuValue,
LGraphCanvas as TLGraphCanvas,
LGraphNode,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {rgthree} from "./rgthree.js";
import {getGroupNodes, getOutputNodes} from "./utils.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
function showQueueNodesMenuIfOutputNodesAreSelected(
existingOptions: (IContextMenuValue<unknown> | null)[],
) {
if (CONFIG_SERVICE.getConfigValue("features.menu_queue_selected_nodes") === false) {
return;
}
const outputNodes = getOutputNodes(Object.values(app.canvas.selected_nodes));
const menuItem = {
content: `Queue Selected Output Nodes (rgthree) &nbsp;`,
className: "rgthree-contextmenu-item",
callback: () => {
rgthree.queueOutputNodes(outputNodes);
},
disabled: !outputNodes.length,
};
let idx = existingOptions.findIndex((o) => o?.content === "Outputs") + 1;
idx = idx || existingOptions.findIndex((o) => o?.content === "Align") + 1;
idx = idx || 3;
existingOptions.splice(idx, 0, menuItem);
}
function showQueueGroupNodesMenuIfGroupIsSelected(
existingOptions: (IContextMenuValue<unknown> | null)[],
) {
if (CONFIG_SERVICE.getConfigValue("features.menu_queue_selected_nodes") === false) {
return;
}
const group =
rgthree.lastCanvasMouseEvent &&
(app.canvas.getCurrentGraph() || app.graph).getGroupOnPos(
rgthree.lastCanvasMouseEvent.canvasX,
rgthree.lastCanvasMouseEvent.canvasY,
);
const outputNodes: LGraphNode[] | null = (group && getOutputNodes(getGroupNodes(group))) || null;
const menuItem = {
content: `Queue Group Output Nodes (rgthree) &nbsp;`,
className: "rgthree-contextmenu-item",
callback: () => {
outputNodes && rgthree.queueOutputNodes(outputNodes);
},
disabled: !outputNodes?.length,
};
let idx = existingOptions.findIndex((o) => o?.content?.startsWith("Queue Selected ")) + 1;
idx = idx || existingOptions.findIndex((o) => o?.content === "Outputs") + 1;
idx = idx || existingOptions.findIndex((o) => o?.content === "Align") + 1;
idx = idx || 3;
existingOptions.splice(idx, 0, menuItem);
}
/**
* Adds a "Queue Node" menu item to all output nodes, working with `rgthree.queueOutputNode` to
* execute only a single node's path.
*/
app.registerExtension({
name: "rgthree.QueueNode",
async beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (
canvas: TLGraphCanvas,
options: (IContextMenuValue<unknown> | null)[],
): (IContextMenuValue<unknown> | null)[] {
const extraOptions = getExtraMenuOptions?.call(this, canvas, options) ?? [];
showQueueNodesMenuIfOutputNodesAreSelected(options);
showQueueGroupNodesMenuIfGroupIsSelected(options);
return extraOptions;
};
},
async setup() {
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const options = getCanvasMenuOptions.apply(this, [...args] as any);
showQueueNodesMenuIfOutputNodesAreSelected(options);
showQueueGroupNodesMenuIfGroupIsSelected(options);
return options;
};
},
});
+51
View File
@@ -0,0 +1,51 @@
import type {LGraphNode} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {BaseNodeModeChanger} from "./base_node_mode_changer.js";
import {NodeTypesString} from "./constants.js";
const MODE_MUTE = 2;
const MODE_ALWAYS = 0;
class MuterNode extends BaseNodeModeChanger {
static override exposedActions = ["Mute all", "Enable all", "Toggle all"];
static override type = NodeTypesString.FAST_MUTER;
static override title = NodeTypesString.FAST_MUTER;
override comfyClass = NodeTypesString.FAST_MUTER;
override readonly modeOn = MODE_ALWAYS;
override readonly modeOff = MODE_MUTE;
constructor(title = MuterNode.title) {
super(title);
this.onConstructed();
}
override async handleAction(action: string) {
if (action === "Mute all") {
for (const widget of this.widgets) {
this.forceWidgetOff(widget, true);
}
} else if (action === "Enable all") {
for (const widget of this.widgets) {
this.forceWidgetOn(widget, true);
}
} else if (action === "Toggle all") {
for (const widget of this.widgets) {
this.forceWidgetToggle(widget, true);
}
}
}
}
app.registerExtension({
name: "rgthree.Muter",
registerCustomNodes() {
MuterNode.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == MuterNode.title) {
(node as any)._tempWidth = node.size[0];
}
},
});
+168
View File
@@ -0,0 +1,168 @@
import type {
LLink,
LGraph,
LGraphCanvas,
LGraphNode as TLGraphNode,
IContextMenuOptions,
ContextMenu,
IContextMenuValue,
Size,
ISerialisedNode,
Point,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {addConnectionLayoutSupport} from "./utils.js";
import {wait} from "rgthree/common/shared_utils.js";
import {ComfyWidgets} from "scripts/widgets.js";
import {BaseCollectorNode} from "./base_node_collector.js";
import {NodeTypesString} from "./constants.js";
/**
* The Collector Node. Takes any number of inputs as connections for nodes and collects them into
* one outputs. The next node will decide what to do with them.
*
* Currently only works with the Fast Muter, Fast Bypasser, and Fast Actions Button.
*/
class CollectorNode extends BaseCollectorNode {
static override type = NodeTypesString.NODE_COLLECTOR;
static override title = NodeTypesString.NODE_COLLECTOR;
override comfyClass = NodeTypesString.NODE_COLLECTOR;
constructor(title = CollectorNode.title) {
super(title);
this.onConstructed();
}
override onConstructed(): boolean {
this.addOutput("Output", "*");
return super.onConstructed();
}
}
/** Legacy "Combiner" */
class CombinerNode extends CollectorNode {
static legacyType = "Node Combiner (rgthree)";
static override title = "‼️ Node Combiner [DEPRECATED]";
constructor(title = CombinerNode.title) {
super(title);
const note = ComfyWidgets["STRING"](
this,
"last_seed",
["STRING", {multiline: true}],
app,
).widget;
note.inputEl!.value =
'The Node Combiner has been renamed to Node Collector. You can right-click and select "Update to Node Collector" to attempt to automatically update.';
note.inputEl!.readOnly = true;
note.inputEl!.style.backgroundColor = "#332222";
note.inputEl!.style.fontWeight = "bold";
note.inputEl!.style.fontStyle = "italic";
note.inputEl!.style.opacity = "0.8";
this.getExtraMenuOptions = (
canvas: LGraphCanvas,
options: (IContextMenuValue<unknown> | null)[],
): (IContextMenuValue<unknown> | null)[] => {
options.splice(options.length - 1, 0, {
content: "‼️ Update to Node Collector",
callback: (
_value: IContextMenuValue,
_options: IContextMenuOptions,
_event: MouseEvent,
_parentMenu: ContextMenu | undefined,
_node: TLGraphNode,
) => {
updateCombinerToCollector(this);
},
});
return [];
};
}
override configure(info: ISerialisedNode) {
super.configure(info);
if (this.title != CombinerNode.title && !this.title.startsWith("‼️")) {
this.title = "‼️ " + this.title;
}
}
}
/**
* Updates a Node Combiner to a Node Collector.
*/
async function updateCombinerToCollector(node: TLGraphNode) {
if (node.type === CombinerNode.legacyType) {
// Create a new CollectorNode.
const newNode = new CollectorNode();
if (node.title != CombinerNode.title) {
newNode.title = node.title.replace("‼️ ", "");
}
// Port the position, size, and properties from the old node.
newNode.pos = [...node.pos] as Point;
newNode.size = [...node.size] as Size;
newNode.properties = {...node.properties};
// We now collect the links data, inputs and outputs, of the old node since these will be
// lost when we remove it.
const links: any[] = [];
const graph = (node.graph || app.graph);
for (const [index, output] of node.outputs.entries()) {
for (const linkId of output.links || []) {
const link: LLink = graph.links[linkId]!;
if (!link) continue;
const targetNode = graph.getNodeById(link.target_id);
links.push({node: newNode, slot: index, targetNode, targetSlot: link.target_slot});
}
}
for (const [index, input] of node.inputs.entries()) {
const linkId = input.link;
if (linkId) {
const link: LLink = graph.links[linkId]!;
const originNode = graph.getNodeById(link.origin_id);
links.push({
node: originNode,
slot: link.origin_slot,
targetNode: newNode,
targetSlot: index,
});
}
}
// Add the new node, remove the old node.
graph.add(newNode);
await wait();
// Now go through and connect the other nodes up as they were.
for (const link of links) {
link.node.connect(link.slot, link.targetNode, link.targetSlot);
}
await wait();
graph.remove(node);
}
}
app.registerExtension({
name: "rgthree.NodeCollector",
registerCustomNodes() {
addConnectionLayoutSupport(CollectorNode, app, [
["Left", "Right"],
["Right", "Left"],
]);
LiteGraph.registerNodeType(CollectorNode.title, CollectorNode);
CollectorNode.category = CollectorNode._category;
},
});
app.registerExtension({
name: "rgthree.NodeCombiner",
registerCustomNodes() {
addConnectionLayoutSupport(CombinerNode, app, [
["Left", "Right"],
["Right", "Left"],
]);
LiteGraph.registerNodeType(CombinerNode.legacyType, CombinerNode);
CombinerNode.category = CombinerNode._category;
},
});

Some files were not shown because too many files have changed in this diff Show More