From b55a462945e26970e501d75d8afcb7d36eabd565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Fri, 15 Dec 2023 21:44:25 +0000 Subject: [PATCH] Add OBS and Twitch nodes. Improve UX significantly. Rework groups from the ground up with a new instancing feature. Open to the public. (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After months of work and over a hundred commits on this repo alone (not to mention the old, half-working repos on GitHub), StreamGraph is finally ready to be shown to the public, even if in an incomplete state. This PR is a culmination of numerous design discussions, re-writes, and hours spent by both @Eroax and myself. Reviewed-on: https://codeberg.org/Eroax/StreamGraph/pulls/18 Co-authored-by: Lera Elvoé Co-committed-by: Lera Elvoé --- .gitignore | 3 + COPYING | 674 ++ README.md | 17 + THIRDPARTY.md | 120 + addons/no-obs-ws/Authenticator.gd | 16 + addons/no-obs-ws/NoOBSWS.gd | 312 + addons/no-obs-ws/Utility/EnumGen.gd | 64 + addons/no-obs-ws/Utility/Enums.gd | 96 + addons/no-obs-ws/Utility/protocol.json | 5786 +++++++++++++++++ addons/no-obs-ws/plugin.cfg | 7 + addons/no-obs-ws/plugin.gd | 12 + addons/no_twitch/chat_socket.gd | 166 + addons/no_twitch/demo/Chat_Join.gd | 33 + addons/no_twitch/demo/demo_scene.gd | 27 + addons/no_twitch/demo/demo_scene.tscn | 81 + addons/no_twitch/demo/test_button.gd | 9 + addons/no_twitch/demo/token_saver.gd | 4 + addons/no_twitch/plugin.cfg | 7 + addons/no_twitch/plugin.gd | 12 + addons/no_twitch/twitch_connection.gd | 140 + addons/no_twitch/websocket_client.gd | 58 + classes/connections/connections.gd | 12 + classes/deck/deck.gd | 388 +- classes/deck/deck_holder.gd | 210 +- classes/deck/deck_node.gd | 146 +- classes/deck/logger.gd | 42 + classes/deck/node_db.gd | 11 +- classes/deck/nodes/bool_constant.gd | 24 + classes/deck/nodes/button.gd | 5 +- classes/deck/nodes/delay.gd | 43 +- classes/deck/nodes/dictionary_get_key.gd | 39 + classes/deck/nodes/expression_node.gd | 50 +- classes/deck/nodes/get_deck_var.gd | 11 +- classes/deck/nodes/group_input_node.gd | 14 +- classes/deck/nodes/group_node.gd | 18 +- classes/deck/nodes/group_output_node.gd | 9 +- classes/deck/nodes/if_true.gd | 38 + classes/deck/nodes/obs_decompose_transform.gd | 46 + classes/deck/nodes/obs_scene_list.gd | 53 + classes/deck/nodes/obs_search_source.gd | 78 + .../deck/nodes/obs_set_source_transform.gd | 108 + classes/deck/nodes/obs_switch_scene.gd | 48 + classes/deck/nodes/obs_vector_to_position.gd | 31 + .../nodes/obs_websocket_generic_request.gd | 62 + classes/deck/nodes/print.gd | 28 +- classes/deck/nodes/process_node.gd | 49 + classes/deck/nodes/set_deck_var.gd | 20 +- classes/deck/nodes/string_constant.gd | 6 +- classes/deck/nodes/test_interleaved_node.gd | 3 + classes/deck/nodes/test_types.gd | 5 +- classes/deck/nodes/twitch_chat_received.gd | 56 + classes/deck/nodes/twitch_send_chat.gd | 52 + classes/deck/nodes/vector_add.gd | 47 + classes/deck/nodes/vector_compose.gd | 33 + classes/deck/nodes/vector_decompose.gd | 41 + classes/deck/nodes/vector_dot.gd | 44 + classes/deck/nodes/vector_multiply.gd | 42 + classes/deck/nodes/vector_normalize.gd | 44 + classes/deck/nodes/vector_subtract.gd | 47 + classes/deck/port.gd | 13 +- classes/deck/renderer_persistence.gd | 140 + classes/deck/search_provider.gd | 7 +- classes/types/deck_type.gd | 3 + dist/logo-flattened.svg | 38 + dist/logo-flattened.svg.import | 37 + dist/logo.svg | 276 + dist/logo.svg.import | 37 + export_presets.cfg | 102 + graph_node_renderer/about_dialog.gd | 11 + graph_node_renderer/about_dialog.tscn | 766 +++ graph_node_renderer/add_node_menu.gd | 6 +- graph_node_renderer/debug_decks_list.gd | 29 + graph_node_renderer/debug_decks_list.tscn | 6 + graph_node_renderer/deck_holder_renderer.gd | 365 +- graph_node_renderer/deck_holder_renderer.tscn | 129 +- .../deck_node_renderer_graph_node.gd | 39 +- .../deck_node_renderer_graph_node.tscn | 2 - .../deck_renderer_graph_edit.gd | 184 +- .../deck_renderer_graph_edit.tscn | 4 + graph_node_renderer/logger_renderer.gd | 46 + graph_node_renderer/logger_renderer.tscn | 79 + .../obs_websocket_setup_dialog.gd | 63 + .../obs_websocket_setup_dialog.tscn | 51 + graph_node_renderer/tab_container_custom.gd | 88 +- graph_node_renderer/twitch_setup_dialog.gd | 21 + graph_node_renderer/twitch_setup_dialog.tscn | 49 + .../unsaved_changes_dialog.tscn | 16 + .../unsaved_changes_dialog_single_deck.gd | 9 + .../unsaved_changes_dialog_single_deck.tscn | 20 + img/.gdignore | 0 img/example1.png | Bin 0 -> 68019 bytes img/example2.png | Bin 0 -> 77938 bytes port_drawer.gd | 54 - port_drawer.tscn | 16 - project.godot | 23 +- script_templates/DeckNode/node_template.gd | 3 + test_node_renderer.gd | 53 - test_node_renderer.tscn | 39 - 98 files changed, 12010 insertions(+), 461 deletions(-) create mode 100644 COPYING create mode 100644 README.md create mode 100644 THIRDPARTY.md create mode 100644 addons/no-obs-ws/Authenticator.gd create mode 100644 addons/no-obs-ws/NoOBSWS.gd create mode 100644 addons/no-obs-ws/Utility/EnumGen.gd create mode 100644 addons/no-obs-ws/Utility/Enums.gd create mode 100644 addons/no-obs-ws/Utility/protocol.json create mode 100644 addons/no-obs-ws/plugin.cfg create mode 100644 addons/no-obs-ws/plugin.gd create mode 100644 addons/no_twitch/chat_socket.gd create mode 100644 addons/no_twitch/demo/Chat_Join.gd create mode 100644 addons/no_twitch/demo/demo_scene.gd create mode 100644 addons/no_twitch/demo/demo_scene.tscn create mode 100644 addons/no_twitch/demo/test_button.gd create mode 100644 addons/no_twitch/demo/token_saver.gd create mode 100644 addons/no_twitch/plugin.cfg create mode 100644 addons/no_twitch/plugin.gd create mode 100644 addons/no_twitch/twitch_connection.gd create mode 100644 addons/no_twitch/websocket_client.gd create mode 100644 classes/connections/connections.gd create mode 100644 classes/deck/logger.gd create mode 100644 classes/deck/nodes/bool_constant.gd create mode 100644 classes/deck/nodes/dictionary_get_key.gd create mode 100644 classes/deck/nodes/if_true.gd create mode 100644 classes/deck/nodes/obs_decompose_transform.gd create mode 100644 classes/deck/nodes/obs_scene_list.gd create mode 100644 classes/deck/nodes/obs_search_source.gd create mode 100644 classes/deck/nodes/obs_set_source_transform.gd create mode 100644 classes/deck/nodes/obs_switch_scene.gd create mode 100644 classes/deck/nodes/obs_vector_to_position.gd create mode 100644 classes/deck/nodes/obs_websocket_generic_request.gd create mode 100644 classes/deck/nodes/process_node.gd create mode 100644 classes/deck/nodes/twitch_chat_received.gd create mode 100644 classes/deck/nodes/twitch_send_chat.gd create mode 100644 classes/deck/nodes/vector_add.gd create mode 100644 classes/deck/nodes/vector_compose.gd create mode 100644 classes/deck/nodes/vector_decompose.gd create mode 100644 classes/deck/nodes/vector_dot.gd create mode 100644 classes/deck/nodes/vector_multiply.gd create mode 100644 classes/deck/nodes/vector_normalize.gd create mode 100644 classes/deck/nodes/vector_subtract.gd create mode 100644 classes/deck/renderer_persistence.gd create mode 100644 dist/logo-flattened.svg create mode 100644 dist/logo-flattened.svg.import create mode 100644 dist/logo.svg create mode 100644 dist/logo.svg.import create mode 100644 export_presets.cfg create mode 100644 graph_node_renderer/about_dialog.gd create mode 100644 graph_node_renderer/about_dialog.tscn create mode 100644 graph_node_renderer/debug_decks_list.gd create mode 100644 graph_node_renderer/debug_decks_list.tscn create mode 100644 graph_node_renderer/logger_renderer.gd create mode 100644 graph_node_renderer/logger_renderer.tscn create mode 100644 graph_node_renderer/obs_websocket_setup_dialog.gd create mode 100644 graph_node_renderer/obs_websocket_setup_dialog.tscn create mode 100644 graph_node_renderer/twitch_setup_dialog.gd create mode 100644 graph_node_renderer/twitch_setup_dialog.tscn create mode 100644 graph_node_renderer/unsaved_changes_dialog.tscn create mode 100644 graph_node_renderer/unsaved_changes_dialog_single_deck.gd create mode 100644 graph_node_renderer/unsaved_changes_dialog_single_deck.tscn create mode 100644 img/.gdignore create mode 100644 img/example1.png create mode 100644 img/example2.png delete mode 100644 port_drawer.gd delete mode 100644 port_drawer.tscn delete mode 100644 test_node_renderer.gd delete mode 100644 test_node_renderer.tscn diff --git a/.gitignore b/.gitignore index 4709183..c62d003 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ # Godot 4+ specific ignores .godot/ + +# distribution folder +dist/*/ diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..ebebd49 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e273f5c --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# StreamGraph is a node graph-based virtual stream deck and livestream automation tool. +## ⚠️ StreamGraph is at an EARLY ALPHA stage! Some things will break! ⚠️ +StreamGraph lets you automate your livestream with a powerful and easy to understand node graph workflow. + +![StreamGraph screenshot](img/example1.png) + +It can connect to Twitch and OBS, allowing you to take stream interactivity to the next level. + +![StreamGraph screenshot](img/example2.png) + +## Current state and capabilities +**StreamGraph is at a very early stage of development**. We cannot guarantee stability at this point and things may change dramatically between releases. As development progresses, the API and nodes will stabilize. If you encounter any issues, please report them on [the issue tracker](https://codeberg.org/Eroax/StreamGraph/issues) with as much detail about how to reproduce them as possible. + +The app can currently connect to Twitch and read and send chats in a single channel. +It can also connect to a single OBS instance (utilizing OBS-WebSocket, which is bundled with modern versions of OBS). + +For more information about nodes that currently exist, [check out the wiki.](https://codeberg.org/Eroax/StreamGraph/wiki) diff --git a/THIRDPARTY.md b/THIRDPARTY.md new file mode 100644 index 0000000..ae5ac67 --- /dev/null +++ b/THIRDPARTY.md @@ -0,0 +1,120 @@ +# Third-party licenses + +StreamGraph uses third-party code. Their licenses and attribution are reproduced below. + +# Engine +## Godot Engine +Upstream: [GitHub](https://github.com/godotengine/godot) +License: MIT +### License text +``` +Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). +Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` +# Libraries +## godot-uuid +Upstream: [GitHub](https://github.com/binogure-studio/godot-uuid) +License: MIT +File(s): `/classes/UUID.gd` +### License text +``` +MIT License + +Copyright (c) 2023 Xavier Sellier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## NoOBSWS +Upstream: [GitHub](https://github.com/yagich/no-obs-ws) +License: BSD 2-Clause +File(s): `/addons/no-obs-ws/*` +### License text +``` +BSD 2-Clause License + +Copyright (c) 2023, Yagich + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` +## NoTwitch +Upstream: [Codeberg](https://codeberg.org/Eroax/NoTwitch) +License: MIT +File(s): `/addons/no_twitch/*` +### License text +``` +MIT License + +Copyright (c) 2023 Eroax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` \ No newline at end of file diff --git a/addons/no-obs-ws/Authenticator.gd b/addons/no-obs-ws/Authenticator.gd new file mode 100644 index 0000000..74e42d4 --- /dev/null +++ b/addons/no-obs-ws/Authenticator.gd @@ -0,0 +1,16 @@ +extends RefCounted + +# The result of the authentication string creation process. Use getter for public access. +var _auth_string: String: + get = get_auth_string + + +func _init(password: String, challenge: String, salt: String) -> void: + var salted := password + salt + var base64_secret := Marshalls.raw_to_base64(salted.sha256_buffer()) + var b64_secret_plus_challenge = base64_secret + challenge + _auth_string = Marshalls.raw_to_base64(b64_secret_plus_challenge.sha256_buffer()) + + +func get_auth_string() -> String: + return _auth_string diff --git a/addons/no-obs-ws/NoOBSWS.gd b/addons/no-obs-ws/NoOBSWS.gd new file mode 100644 index 0000000..ea83718 --- /dev/null +++ b/addons/no-obs-ws/NoOBSWS.gd @@ -0,0 +1,312 @@ +extends Node +class_name NoOBSWS + +const Authenticator := preload("res://addons/no-obs-ws/Authenticator.gd") +const Enums := preload("res://addons/no-obs-ws/Utility/Enums.gd") + +var _ws: WebSocketPeer +# {request_id: RequestResponse} +var _requests: Dictionary = {} +var _batch_requests: Dictionary = {} + +const WS_URL := "127.0.0.1:%s" + +signal connection_ready() +signal connection_failed() +signal connection_closed_clean(code: int, reason: String) + +signal error(message: String) + +signal event_received(event: Message) + +signal _auth_required() + + +func connect_to_obsws(port: int, password: String = "") -> void: + _ws = WebSocketPeer.new() + _ws.connect_to_url(WS_URL % port) + _auth_required.connect(_authenticate.bind(password)) + + +func make_generic_request(request_type: String, request_data: Dictionary = {}) -> RequestResponse: + var response := RequestResponse.new() + var message := Message.new() + + var crypto := Crypto.new() + var request_id := crypto.generate_random_bytes(16).hex_encode() + + var data := { + "request_type": request_type, + "request_id": request_id, + "request_data": request_data, + } + message._d.merge(data, true) + + message.op_code = Enums.WebSocketOpCode.REQUEST + + response.id = request_id + response.type = request_type + + _requests[request_id] = response + + _send_message(message) + + return response + + +func make_batch_request(halt_on_failure: bool = false, execution_type: Enums.RequestBatchExecutionType = Enums.RequestBatchExecutionType.SERIAL_REALTIME) -> BatchRequest: + var batch_request := BatchRequest.new() + + var crypto := Crypto.new() + var request_id := crypto.generate_random_bytes(16).hex_encode() + + batch_request._id = request_id + batch_request._send_callback = _send_message + + batch_request.halt_on_failure = halt_on_failure + batch_request.execution_type = execution_type + + _batch_requests[request_id] = batch_request + + return batch_request + + +func _process(_delta: float) -> void: + if is_instance_valid(_ws): + _poll_socket() + + +func _poll_socket() -> void: + _ws.poll() + + var state = _ws.get_ready_state() + match state: + WebSocketPeer.STATE_OPEN: + while _ws.get_available_packet_count(): + _handle_packet(_ws.get_packet()) + WebSocketPeer.STATE_CLOSING: + pass + WebSocketPeer.STATE_CLOSED: + if _ws.get_close_code() == -1: + connection_failed.emit() + else: + connection_closed_clean.emit(_ws.get_close_code(), _ws.get_close_reason()) + _ws = null + + +func _handle_packet(packet: PackedByteArray) -> void: + var message = Message.from_json(packet.get_string_from_utf8()) + #print("got message with code ", message.op_code) + _handle_message(message) + + +func _handle_message(message: Message) -> void: +# print(message) + match message.op_code: + Enums.WebSocketOpCode.HELLO: + if message.get("authentication") != null: + _auth_required.emit(message) + else: + var m = Message.new() + m.op_code = Enums.WebSocketOpCode.IDENTIFY + _send_message(m) + + Enums.WebSocketOpCode.IDENTIFIED: + connection_ready.emit() + + Enums.WebSocketOpCode.EVENT: + event_received.emit(message) + + Enums.WebSocketOpCode.REQUEST_RESPONSE: + #print("Req Response") + var id = message.get_data().get("request_id") + if id == null: + error.emit("Received request response, but there was no request id field.") + return + + var response = _requests.get(id) as RequestResponse + if response == null: + error.emit("Received request response, but there was no request made with that id.") + return + + response.message = message + + response.response_received.emit() + _requests.erase(id) + + Enums.WebSocketOpCode.REQUEST_BATCH_RESPONSE: + var id = message.get_data().get("request_id") + if id == null: + error.emit("Received batch request response, but there was no request id field.") + return + + var response = _batch_requests.get(id) as BatchRequest + if response == null: + error.emit("Received batch request response, but there was no request made with that id.") + return + + response.response = message + + response.response_received.emit() + _batch_requests.erase(id) + + +func _send_message(message: Message) -> void: + _ws.send_text(message.to_obsws_json()) + + +func _authenticate(message: Message, password: String) -> void: + var authenticator = Authenticator.new( + password, + message.authentication.challenge, + message.authentication.salt, + ) + var auth_string = authenticator.get_auth_string() + var m = Message.new() + m.op_code = Enums.WebSocketOpCode.IDENTIFY + m._d["authentication"] = auth_string + #print("MY RESPONSE: ") + #print(m) + _send_message(m) + + +class Message: + var op_code: int + var _d: Dictionary = {"rpc_version": 1} + + func _get(property: StringName): + if property in _d: + return _d[property] + else: + return null + + + func _get_property_list() -> Array: + var prop_list = [] + _d.keys().map( + func(x): + var d = { + "name": x, + "type": typeof(_d[x]) + } + prop_list.append(d) + ) + return prop_list + + + func to_obsws_json() -> String: + var data = { + "op": op_code, + "d": {} + } + + data.d = snake_to_camel_recursive(_d) + + return JSON.stringify(data) + + + func get_data() -> Dictionary: + return _d + + + func _to_string() -> String: + return var_to_str(_d) + + + static func from_json(json: String) -> Message: + var ev = Message.new() + var dictified = JSON.parse_string(json) + + if dictified == null: + return null + + dictified = dictified as Dictionary + ev.op_code = dictified.get("op", -1) + var data = dictified.get("d", null) + if data == null: + return null + + data = data as Dictionary + ev._d = camel_to_snake_recursive(data) + + return ev + + + static func camel_to_snake_recursive(d: Dictionary) -> Dictionary: + var snaked = {} + for prop in d: + prop = prop as String + if d[prop] is Dictionary: + snaked[prop.to_snake_case()] = camel_to_snake_recursive(d[prop]) + else: + snaked[prop.to_snake_case()] = d[prop] + return snaked + + + static func snake_to_camel_recursive(d: Dictionary) -> Dictionary: + var cameled = {} + for prop in d: + prop = prop as String + if d[prop] is Dictionary: + cameled[prop.to_camel_case()] = snake_to_camel_recursive(d[prop]) + else: + cameled[prop.to_camel_case()] = d[prop] + return cameled + + +class RequestResponse: + signal response_received() + + var id: String + var type: String + var message: Message + + +class BatchRequest: + signal response_received() + + var _id: String + var _send_callback: Callable + + var halt_on_failure: bool = false + var execution_type: Enums.RequestBatchExecutionType = Enums.RequestBatchExecutionType.SERIAL_REALTIME + + var requests: Array[Message] + # {String: int} + var request_ids: Dictionary + + var response: Message = null + + func send() -> void: + var message = Message.new() + message.op_code = Enums.WebSocketOpCode.REQUEST_BATCH + message._d["halt_on_failure"] = halt_on_failure + message._d["execution_type"] = execution_type + message._d["request_id"] = _id + message._d["requests"] = [] + for r in requests: + message._d.requests.append(Message.snake_to_camel_recursive(r.get_data())) + + _send_callback.call(message) + + + func add_request(request_type: String, request_id: String = "", request_data: Dictionary = {}) -> int: + var message = Message.new() + + if request_id == "": + var crypto := Crypto.new() + request_id = crypto.generate_random_bytes(16).hex_encode() + + var data := { + "request_type": request_type, + "request_id": request_id, + "request_data": request_data, + } + + message._d.merge(data, true) + message.op_code = Enums.WebSocketOpCode.REQUEST + + requests.append(message) + request_ids[request_id] = requests.size() - 1 + + return request_ids[request_id] diff --git a/addons/no-obs-ws/Utility/EnumGen.gd b/addons/no-obs-ws/Utility/EnumGen.gd new file mode 100644 index 0000000..47b2ca0 --- /dev/null +++ b/addons/no-obs-ws/Utility/EnumGen.gd @@ -0,0 +1,64 @@ +static func generate_enums(protocol_json_path: String, output_to_path: String) -> void: + var protocol := FileAccess.open(protocol_json_path, FileAccess.READ).get_as_text() + var protocol_json: Dictionary = JSON.parse_string(protocol) + var res := "# This file is automatically generated, please do not change it. If you wish to edit it, check /addons/deckobsws/Utility/EnumGen.gd\n\n" + for e in protocol_json.enums: + # if all are deprecated, don't make the enum + var deprecated_count: int + for enumlet in e.enumIdentifiers: + if enumlet.deprecated: + deprecated_count += 1 + + if deprecated_count == e.enumIdentifiers.size(): + continue + + res += "enum %s {\n" % e.enumType + + for enumlet in e.enumIdentifiers: + var enumlet_value: int + + match typeof(enumlet.enumValue): + TYPE_FLOAT: + enumlet_value = int(enumlet.enumValue) + TYPE_STRING when "<<" in enumlet.enumValue: + enumlet_value = tokenize_lbitshift(enumlet.enumValue) + TYPE_STRING when "|" in enumlet.enumValue: + var token: String = (enumlet.enumValue as String)\ + .trim_prefix("(")\ + .trim_suffix(")") + var split := Array(token.split("|")).map( + func(x: String): + return x.strip_edges() + ) + var calc: int + for enum_partial in e.enumIdentifiers: + if enum_partial.enumIdentifier not in split: + continue + + + calc |= tokenize_lbitshift(enum_partial.enumValue) + enumlet_value = calc + TYPE_STRING: + enumlet_value = int(enumlet.enumValue) + + res += "\t%s = %s,\n" % [ + (enumlet.enumIdentifier as String).to_snake_case().to_upper(), + enumlet_value + ] + + res += "}\n\n\n" + + var result_file := FileAccess.open(output_to_path, FileAccess.WRITE) + result_file.store_string(res.strip_edges() + "\n") + + +static func tokenize_lbitshift(s: String) -> int: + var tokens := Array(s\ + .trim_prefix("(")\ + .trim_suffix(")")\ + .split("<<")).map( + func(x: String): + return int(x) + ) + + return tokens[0] << tokens [1] diff --git a/addons/no-obs-ws/Utility/Enums.gd b/addons/no-obs-ws/Utility/Enums.gd new file mode 100644 index 0000000..b41b4d5 --- /dev/null +++ b/addons/no-obs-ws/Utility/Enums.gd @@ -0,0 +1,96 @@ +# This file is automatically generated, please do not change it. If you wish to edit it, check /addons/deckobsws/Utility/EnumGen.gd + +enum EventSubscription { + NONE = 0, + GENERAL = 1, + CONFIG = 2, + SCENES = 4, + INPUTS = 8, + TRANSITIONS = 16, + FILTERS = 32, + OUTPUTS = 64, + SCENE_ITEMS = 128, + MEDIA_INPUTS = 256, + VENDORS = 512, + UI = 1024, + ALL = 2047, + INPUT_VOLUME_METERS = 65536, + INPUT_ACTIVE_STATE_CHANGED = 131072, + INPUT_SHOW_STATE_CHANGED = 262144, + SCENE_ITEM_TRANSFORM_CHANGED = 524288, +} + + +enum RequestBatchExecutionType { + NONE = -1, + SERIAL_REALTIME = 0, + SERIAL_FRAME = 1, + PARALLEL = 2, +} + + +enum RequestStatus { + UNKNOWN = 0, + NO_ERROR = 10, + SUCCESS = 100, + MISSING_REQUEST_TYPE = 203, + UNKNOWN_REQUEST_TYPE = 204, + GENERIC_ERROR = 205, + UNSUPPORTED_REQUEST_BATCH_EXECUTION_TYPE = 206, + MISSING_REQUEST_FIELD = 300, + MISSING_REQUEST_DATA = 301, + INVALID_REQUEST_FIELD = 400, + INVALID_REQUEST_FIELD_TYPE = 401, + REQUEST_FIELD_OUT_OF_RANGE = 402, + REQUEST_FIELD_EMPTY = 403, + TOO_MANY_REQUEST_FIELDS = 404, + OUTPUT_RUNNING = 500, + OUTPUT_NOT_RUNNING = 501, + OUTPUT_PAUSED = 502, + OUTPUT_NOT_PAUSED = 503, + OUTPUT_DISABLED = 504, + STUDIO_MODE_ACTIVE = 505, + STUDIO_MODE_NOT_ACTIVE = 506, + RESOURCE_NOT_FOUND = 600, + RESOURCE_ALREADY_EXISTS = 601, + INVALID_RESOURCE_TYPE = 602, + NOT_ENOUGH_RESOURCES = 603, + INVALID_RESOURCE_STATE = 604, + INVALID_INPUT_KIND = 605, + RESOURCE_NOT_CONFIGURABLE = 606, + INVALID_FILTER_KIND = 607, + RESOURCE_CREATION_FAILED = 700, + RESOURCE_ACTION_FAILED = 701, + REQUEST_PROCESSING_FAILED = 702, + CANNOT_ACT = 703, +} + + +enum WebSocketCloseCode { + DONT_CLOSE = 0, + UNKNOWN_REASON = 4000, + MESSAGE_DECODE_ERROR = 4002, + MISSING_DATA_FIELD = 4003, + INVALID_DATA_FIELD_TYPE = 4004, + INVALID_DATA_FIELD_VALUE = 4005, + UNKNOWN_OP_CODE = 4006, + NOT_IDENTIFIED = 4007, + ALREADY_IDENTIFIED = 4008, + AUTHENTICATION_FAILED = 4009, + UNSUPPORTED_RPC_VERSION = 4010, + SESSION_INVALIDATED = 4011, + UNSUPPORTED_FEATURE = 4012, +} + + +enum WebSocketOpCode { + HELLO = 0, + IDENTIFY = 1, + IDENTIFIED = 2, + REIDENTIFY = 3, + EVENT = 5, + REQUEST = 6, + REQUEST_RESPONSE = 7, + REQUEST_BATCH = 8, + REQUEST_BATCH_RESPONSE = 9, +} diff --git a/addons/no-obs-ws/Utility/protocol.json b/addons/no-obs-ws/Utility/protocol.json new file mode 100644 index 0000000..7111046 --- /dev/null +++ b/addons/no-obs-ws/Utility/protocol.json @@ -0,0 +1,5786 @@ +{ + "enums": [ + { + "enumType": "EventSubscription", + "enumIdentifiers": [ + { + "description": "Subcription value used to disable all events.", + "enumIdentifier": "None", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "Subscription value to receive events in the `General` category.", + "enumIdentifier": "General", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 0)" + }, + { + "description": "Subscription value to receive events in the `Config` category.", + "enumIdentifier": "Config", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 1)" + }, + { + "description": "Subscription value to receive events in the `Scenes` category.", + "enumIdentifier": "Scenes", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 2)" + }, + { + "description": "Subscription value to receive events in the `Inputs` category.", + "enumIdentifier": "Inputs", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 3)" + }, + { + "description": "Subscription value to receive events in the `Transitions` category.", + "enumIdentifier": "Transitions", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 4)" + }, + { + "description": "Subscription value to receive events in the `Filters` category.", + "enumIdentifier": "Filters", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 5)" + }, + { + "description": "Subscription value to receive events in the `Outputs` category.", + "enumIdentifier": "Outputs", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 6)" + }, + { + "description": "Subscription value to receive events in the `SceneItems` category.", + "enumIdentifier": "SceneItems", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 7)" + }, + { + "description": "Subscription value to receive events in the `MediaInputs` category.", + "enumIdentifier": "MediaInputs", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 8)" + }, + { + "description": "Subscription value to receive the `VendorEvent` event.", + "enumIdentifier": "Vendors", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 9)" + }, + { + "description": "Subscription value to receive events in the `Ui` category.", + "enumIdentifier": "Ui", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 10)" + }, + { + "description": "Helper to receive all non-high-volume events.", + "enumIdentifier": "All", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(General | Config | Scenes | Inputs | Transitions | Filters | Outputs | SceneItems | MediaInputs | Vendors | Ui)" + }, + { + "description": "Subscription value to receive the `InputVolumeMeters` high-volume event.", + "enumIdentifier": "InputVolumeMeters", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 16)" + }, + { + "description": "Subscription value to receive the `InputActiveStateChanged` high-volume event.", + "enumIdentifier": "InputActiveStateChanged", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 17)" + }, + { + "description": "Subscription value to receive the `InputShowStateChanged` high-volume event.", + "enumIdentifier": "InputShowStateChanged", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 18)" + }, + { + "description": "Subscription value to receive the `SceneItemTransformChanged` high-volume event.", + "enumIdentifier": "SceneItemTransformChanged", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 19)" + } + ] + }, + { + "enumType": "RequestBatchExecutionType", + "enumIdentifiers": [ + { + "description": "Not a request batch.", + "enumIdentifier": "None", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "-1" + }, + { + "description": "A request batch which processes all requests serially, as fast as possible.\n\nNote: To introduce artificial delay, use the `Sleep` request and the `sleepMillis` request field.", + "enumIdentifier": "SerialRealtime", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "A request batch type which processes all requests serially, in sync with the graphics thread. Designed to provide high accuracy for animations.\n\nNote: To introduce artificial delay, use the `Sleep` request and the `sleepFrames` request field.", + "enumIdentifier": "SerialFrame", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 1 + }, + { + "description": "A request batch type which processes all requests using all available threads in the thread pool.\n\nNote: This is mainly experimental, and only really shows its colors during requests which require lots of\nactive processing, like `GetSourceScreenshot`.", + "enumIdentifier": "Parallel", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 2 + } + ] + }, + { + "enumType": "RequestStatus", + "enumIdentifiers": [ + { + "description": "Unknown status, should never be used.", + "enumIdentifier": "Unknown", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "For internal use to signify a successful field check.", + "enumIdentifier": "NoError", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 10 + }, + { + "description": "The request has succeeded.", + "enumIdentifier": "Success", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 100 + }, + { + "description": "The `requestType` field is missing from the request data.", + "enumIdentifier": "MissingRequestType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 203 + }, + { + "description": "The request type is invalid or does not exist.", + "enumIdentifier": "UnknownRequestType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 204 + }, + { + "description": "Generic error code.\n\nNote: A comment is required to be provided by obs-websocket.", + "enumIdentifier": "GenericError", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 205 + }, + { + "description": "The request batch execution type is not supported.", + "enumIdentifier": "UnsupportedRequestBatchExecutionType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 206 + }, + { + "description": "A required request field is missing.", + "enumIdentifier": "MissingRequestField", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 300 + }, + { + "description": "The request does not have a valid requestData object.", + "enumIdentifier": "MissingRequestData", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 301 + }, + { + "description": "Generic invalid request field message.\n\nNote: A comment is required to be provided by obs-websocket.", + "enumIdentifier": "InvalidRequestField", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 400 + }, + { + "description": "A request field has the wrong data type.", + "enumIdentifier": "InvalidRequestFieldType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 401 + }, + { + "description": "A request field (number) is outside of the allowed range.", + "enumIdentifier": "RequestFieldOutOfRange", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 402 + }, + { + "description": "A request field (string or array) is empty and cannot be.", + "enumIdentifier": "RequestFieldEmpty", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 403 + }, + { + "description": "There are too many request fields (eg. a request takes two optionals, where only one is allowed at a time).", + "enumIdentifier": "TooManyRequestFields", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 404 + }, + { + "description": "An output is running and cannot be in order to perform the request.", + "enumIdentifier": "OutputRunning", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 500 + }, + { + "description": "An output is not running and should be.", + "enumIdentifier": "OutputNotRunning", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 501 + }, + { + "description": "An output is paused and should not be.", + "enumIdentifier": "OutputPaused", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 502 + }, + { + "description": "An output is not paused and should be.", + "enumIdentifier": "OutputNotPaused", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 503 + }, + { + "description": "An output is disabled and should not be.", + "enumIdentifier": "OutputDisabled", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 504 + }, + { + "description": "Studio mode is active and cannot be.", + "enumIdentifier": "StudioModeActive", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 505 + }, + { + "description": "Studio mode is not active and should be.", + "enumIdentifier": "StudioModeNotActive", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 506 + }, + { + "description": "The resource was not found.\n\nNote: Resources are any kind of object in obs-websocket, like inputs, profiles, outputs, etc.", + "enumIdentifier": "ResourceNotFound", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 600 + }, + { + "description": "The resource already exists.", + "enumIdentifier": "ResourceAlreadyExists", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 601 + }, + { + "description": "The type of resource found is invalid.", + "enumIdentifier": "InvalidResourceType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 602 + }, + { + "description": "There are not enough instances of the resource in order to perform the request.", + "enumIdentifier": "NotEnoughResources", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 603 + }, + { + "description": "The state of the resource is invalid. For example, if the resource is blocked from being accessed.", + "enumIdentifier": "InvalidResourceState", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 604 + }, + { + "description": "The specified input (obs_source_t-OBS_SOURCE_TYPE_INPUT) had the wrong kind.", + "enumIdentifier": "InvalidInputKind", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 605 + }, + { + "description": "The resource does not support being configured.\n\nThis is particularly relevant to transitions, where they do not always have changeable settings.", + "enumIdentifier": "ResourceNotConfigurable", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 606 + }, + { + "description": "The specified filter (obs_source_t-OBS_SOURCE_TYPE_FILTER) had the wrong kind.", + "enumIdentifier": "InvalidFilterKind", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 607 + }, + { + "description": "Creating the resource failed.", + "enumIdentifier": "ResourceCreationFailed", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 700 + }, + { + "description": "Performing an action on the resource failed.", + "enumIdentifier": "ResourceActionFailed", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 701 + }, + { + "description": "Processing the request failed unexpectedly.\n\nNote: A comment is required to be provided by obs-websocket.", + "enumIdentifier": "RequestProcessingFailed", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 702 + }, + { + "description": "The combination of request fields cannot be used to perform an action.", + "enumIdentifier": "CannotAct", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 703 + } + ] + }, + { + "enumType": "ObsOutputState", + "enumIdentifiers": [ + { + "description": "Unknown state.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_UNKNOWN", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_UNKNOWN" + }, + { + "description": "The output is starting.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_STARTING", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_STARTING" + }, + { + "description": "The input has started.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_STARTED", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_STARTED" + }, + { + "description": "The output is stopping.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_STOPPING", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_STOPPING" + }, + { + "description": "The output has stopped.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_STOPPED", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_STOPPED" + }, + { + "description": "The output has disconnected and is reconnecting.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_RECONNECTING", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_RECONNECTING" + }, + { + "description": "The output has reconnected successfully.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_RECONNECTED", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.1.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_RECONNECTED" + }, + { + "description": "The output is now paused.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_PAUSED", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.1.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_PAUSED" + }, + { + "description": "The output has been resumed (unpaused).", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_RESUMED", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_RESUMED" + } + ] + }, + { + "enumType": "ObsMediaInputAction", + "enumIdentifiers": [ + { + "description": "No action.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NONE", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NONE" + }, + { + "description": "Play the media input.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY" + }, + { + "description": "Pause the media input.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE" + }, + { + "description": "Stop the media input.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP" + }, + { + "description": "Restart the media input.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART" + }, + { + "description": "Go to the next playlist item.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NEXT", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NEXT" + }, + { + "description": "Go to the previous playlist item.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PREVIOUS", + "rpcVersion": 1, + "deprecated": true, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PREVIOUS" + } + ] + }, + { + "enumType": "WebSocketCloseCode", + "enumIdentifiers": [ + { + "description": "For internal use only to tell the request handler not to perform any close action.", + "enumIdentifier": "DontClose", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "Unknown reason, should never be used.", + "enumIdentifier": "UnknownReason", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4000 + }, + { + "description": "The server was unable to decode the incoming websocket message.", + "enumIdentifier": "MessageDecodeError", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4002 + }, + { + "description": "A data field is required but missing from the payload.", + "enumIdentifier": "MissingDataField", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4003 + }, + { + "description": "A data field's value type is invalid.", + "enumIdentifier": "InvalidDataFieldType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4004 + }, + { + "description": "A data field's value is invalid.", + "enumIdentifier": "InvalidDataFieldValue", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4005 + }, + { + "description": "The specified `op` was invalid or missing.", + "enumIdentifier": "UnknownOpCode", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4006 + }, + { + "description": "The client sent a websocket message without first sending `Identify` message.", + "enumIdentifier": "NotIdentified", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4007 + }, + { + "description": "The client sent an `Identify` message while already identified.\n\nNote: Once a client has identified, only `Reidentify` may be used to change session parameters.", + "enumIdentifier": "AlreadyIdentified", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4008 + }, + { + "description": "The authentication attempt (via `Identify`) failed.", + "enumIdentifier": "AuthenticationFailed", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4009 + }, + { + "description": "The server detected the usage of an old version of the obs-websocket RPC protocol.", + "enumIdentifier": "UnsupportedRpcVersion", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4010 + }, + { + "description": "The websocket session has been invalidated by the obs-websocket server.\n\nNote: This is the code used by the `Kick` button in the UI Session List. If you receive this code, you must not automatically reconnect.", + "enumIdentifier": "SessionInvalidated", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4011 + }, + { + "description": "A requested feature is not supported due to hardware/software limitations.", + "enumIdentifier": "UnsupportedFeature", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4012 + } + ] + }, + { + "enumType": "WebSocketOpCode", + "enumIdentifiers": [ + { + "description": "The initial message sent by obs-websocket to newly connected clients.", + "enumIdentifier": "Hello", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "The message sent by a newly connected client to obs-websocket in response to a `Hello`.", + "enumIdentifier": "Identify", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 1 + }, + { + "description": "The response sent by obs-websocket to a client after it has successfully identified with obs-websocket.", + "enumIdentifier": "Identified", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 2 + }, + { + "description": "The message sent by an already-identified client to update identification parameters.", + "enumIdentifier": "Reidentify", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 3 + }, + { + "description": "The message sent by obs-websocket containing an event payload.", + "enumIdentifier": "Event", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 5 + }, + { + "description": "The message sent by a client to obs-websocket to perform a request.", + "enumIdentifier": "Request", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 6 + }, + { + "description": "The message sent by obs-websocket in response to a particular request from a client.", + "enumIdentifier": "RequestResponse", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 7 + }, + { + "description": "The message sent by a client to obs-websocket to perform a batch of requests.", + "enumIdentifier": "RequestBatch", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 8 + }, + { + "description": "The message sent by obs-websocket in response to a particular batch of requests from a client.", + "enumIdentifier": "RequestBatchResponse", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 9 + } + ] + } + ], + "requests": [ + { + "description": "Gets the value of a \"slot\" from the selected persistent data realm.", + "requestType": "GetPersistentData", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "realm", + "valueType": "String", + "valueDescription": "The data realm to select. `OBS_WEBSOCKET_DATA_REALM_GLOBAL` or `OBS_WEBSOCKET_DATA_REALM_PROFILE`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "slotName", + "valueType": "String", + "valueDescription": "The name of the slot to retrieve data from", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "slotValue", + "valueType": "Any", + "valueDescription": "Value associated with the slot. `null` if not set" + } + ] + }, + { + "description": "Sets the value of a \"slot\" from the selected persistent data realm.", + "requestType": "SetPersistentData", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "realm", + "valueType": "String", + "valueDescription": "The data realm to select. `OBS_WEBSOCKET_DATA_REALM_GLOBAL` or `OBS_WEBSOCKET_DATA_REALM_PROFILE`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "slotName", + "valueType": "String", + "valueDescription": "The name of the slot to retrieve data from", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "slotValue", + "valueType": "Any", + "valueDescription": "The value to apply to the slot", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all scene collections", + "requestType": "GetSceneCollectionList", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "currentSceneCollectionName", + "valueType": "String", + "valueDescription": "The name of the current scene collection" + }, + { + "valueName": "sceneCollections", + "valueType": "Array", + "valueDescription": "Array of all available scene collections" + } + ] + }, + { + "description": "Switches to a scene collection.\n\nNote: This will block until the collection has finished changing.", + "requestType": "SetCurrentSceneCollection", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "sceneCollectionName", + "valueType": "String", + "valueDescription": "Name of the scene collection to switch to", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Creates a new scene collection, switching to it in the process.\n\nNote: This will block until the collection has finished changing.", + "requestType": "CreateSceneCollection", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "sceneCollectionName", + "valueType": "String", + "valueDescription": "Name for the new scene collection", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all profiles", + "requestType": "GetProfileList", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "currentProfileName", + "valueType": "String", + "valueDescription": "The name of the current profile" + }, + { + "valueName": "profiles", + "valueType": "Array", + "valueDescription": "Array of all available profiles" + } + ] + }, + { + "description": "Switches to a profile.", + "requestType": "SetCurrentProfile", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name of the profile to switch to", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Creates a new profile, switching to it in the process", + "requestType": "CreateProfile", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name for the new profile", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Removes a profile. If the current profile is chosen, it will change to a different profile first.", + "requestType": "RemoveProfile", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name of the profile to remove", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets a parameter from the current profile's configuration.", + "requestType": "GetProfileParameter", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "parameterCategory", + "valueType": "String", + "valueDescription": "Category of the parameter to get", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "parameterName", + "valueType": "String", + "valueDescription": "Name of the parameter to get", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "parameterValue", + "valueType": "String", + "valueDescription": "Value associated with the parameter. `null` if not set and no default" + }, + { + "valueName": "defaultParameterValue", + "valueType": "String", + "valueDescription": "Default value associated with the parameter. `null` if no default" + } + ] + }, + { + "description": "Sets the value of a parameter in the current profile's configuration.", + "requestType": "SetProfileParameter", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "parameterCategory", + "valueType": "String", + "valueDescription": "Category of the parameter to set", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "parameterName", + "valueType": "String", + "valueDescription": "Name of the parameter to set", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "parameterValue", + "valueType": "String", + "valueDescription": "Value of the parameter to set. Use `null` to delete", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the current video settings.\n\nNote: To get the true FPS value, divide the FPS numerator by the FPS denominator. Example: `60000/1001`", + "requestType": "GetVideoSettings", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "fpsNumerator", + "valueType": "Number", + "valueDescription": "Numerator of the fractional FPS value" + }, + { + "valueName": "fpsDenominator", + "valueType": "Number", + "valueDescription": "Denominator of the fractional FPS value" + }, + { + "valueName": "baseWidth", + "valueType": "Number", + "valueDescription": "Width of the base (canvas) resolution in pixels" + }, + { + "valueName": "baseHeight", + "valueType": "Number", + "valueDescription": "Height of the base (canvas) resolution in pixels" + }, + { + "valueName": "outputWidth", + "valueType": "Number", + "valueDescription": "Width of the output resolution in pixels" + }, + { + "valueName": "outputHeight", + "valueType": "Number", + "valueDescription": "Height of the output resolution in pixels" + } + ] + }, + { + "description": "Sets the current video settings.\n\nNote: Fields must be specified in pairs. For example, you cannot set only `baseWidth` without needing to specify `baseHeight`.", + "requestType": "SetVideoSettings", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "fpsNumerator", + "valueType": "Number", + "valueDescription": "Numerator of the fractional FPS value", + "valueRestrictions": ">= 1", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "fpsDenominator", + "valueType": "Number", + "valueDescription": "Denominator of the fractional FPS value", + "valueRestrictions": ">= 1", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "baseWidth", + "valueType": "Number", + "valueDescription": "Width of the base (canvas) resolution in pixels", + "valueRestrictions": ">= 1, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "baseHeight", + "valueType": "Number", + "valueDescription": "Height of the base (canvas) resolution in pixels", + "valueRestrictions": ">= 1, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "outputWidth", + "valueType": "Number", + "valueDescription": "Width of the output resolution in pixels", + "valueRestrictions": ">= 1, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "outputHeight", + "valueType": "Number", + "valueDescription": "Height of the output resolution in pixels", + "valueRestrictions": ">= 1, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + } + ], + "responseFields": [] + }, + { + "description": "Gets the current stream service settings (stream destination).", + "requestType": "GetStreamServiceSettings", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "streamServiceType", + "valueType": "String", + "valueDescription": "Stream service type, like `rtmp_custom` or `rtmp_common`" + }, + { + "valueName": "streamServiceSettings", + "valueType": "Object", + "valueDescription": "Stream service settings" + } + ] + }, + { + "description": "Sets the current stream service settings (stream destination).\n\nNote: Simple RTMP settings can be set with type `rtmp_custom` and the settings fields `server` and `key`.", + "requestType": "SetStreamServiceSettings", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "streamServiceType", + "valueType": "String", + "valueDescription": "Type of stream service to apply. Example: `rtmp_common` or `rtmp_custom`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "streamServiceSettings", + "valueType": "Object", + "valueDescription": "Settings to apply to the service", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the current directory that the record output is set to.", + "requestType": "GetRecordDirectory", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "recordDirectory", + "valueType": "String", + "valueDescription": "Output directory" + } + ] + }, + { + "description": "Gets an array of all of a source's filters.", + "requestType": "GetSourceFilterList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "filters", + "valueType": "Array", + "valueDescription": "Array of filters" + } + ] + }, + { + "description": "Gets the default settings for a filter kind.", + "requestType": "GetSourceFilterDefaultSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "filterKind", + "valueType": "String", + "valueDescription": "Filter kind to get the default settings for", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "defaultFilterSettings", + "valueType": "Object", + "valueDescription": "Object of default settings for the filter kind" + } + ] + }, + { + "description": "Creates a new filter, adding it to the specified source.", + "requestType": "CreateSourceFilter", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to add the filter to", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the new filter to be created", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterKind", + "valueType": "String", + "valueDescription": "The kind of filter to be created", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterSettings", + "valueType": "Object", + "valueDescription": "Settings object to initialize the filter with", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Default settings used" + } + ], + "responseFields": [] + }, + { + "description": "Removes a filter from a source.", + "requestType": "RemoveSourceFilter", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter to remove", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the name of a source filter (rename).", + "requestType": "SetSourceFilterName", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Current name of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "newFilterName", + "valueType": "String", + "valueDescription": "New name for the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the info for a specific source filter.", + "requestType": "GetSourceFilter", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "filterEnabled", + "valueType": "Boolean", + "valueDescription": "Whether the filter is enabled" + }, + { + "valueName": "filterIndex", + "valueType": "Number", + "valueDescription": "Index of the filter in the list, beginning at 0" + }, + { + "valueName": "filterKind", + "valueType": "String", + "valueDescription": "The kind of filter" + }, + { + "valueName": "filterSettings", + "valueType": "Object", + "valueDescription": "Settings object associated with the filter" + } + ] + }, + { + "description": "Sets the index position of a filter on a source.", + "requestType": "SetSourceFilterIndex", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterIndex", + "valueType": "Number", + "valueDescription": "New index position of the filter", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the settings of a source filter.", + "requestType": "SetSourceFilterSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter to set the settings of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterSettings", + "valueType": "Object", + "valueDescription": "Object of settings to apply", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "overlay", + "valueType": "Boolean", + "valueDescription": "True == apply the settings on top of existing ones, False == reset the input to its defaults, then apply settings.", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "true" + } + ], + "responseFields": [] + }, + { + "description": "Sets the enable state of a source filter.", + "requestType": "SetSourceFilterEnabled", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterEnabled", + "valueType": "Boolean", + "valueDescription": "New enable state of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets data about the current plugin and RPC version.", + "requestType": "GetVersion", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [], + "responseFields": [ + { + "valueName": "obsVersion", + "valueType": "String", + "valueDescription": "Current OBS Studio version" + }, + { + "valueName": "obsWebSocketVersion", + "valueType": "String", + "valueDescription": "Current obs-websocket version" + }, + { + "valueName": "rpcVersion", + "valueType": "Number", + "valueDescription": "Current latest obs-websocket RPC version" + }, + { + "valueName": "availableRequests", + "valueType": "Array", + "valueDescription": "Array of available RPC requests for the currently negotiated RPC version" + }, + { + "valueName": "supportedImageFormats", + "valueType": "Array", + "valueDescription": "Image formats available in `GetSourceScreenshot` and `SaveSourceScreenshot` requests." + }, + { + "valueName": "platform", + "valueType": "String", + "valueDescription": "Name of the platform. Usually `windows`, `macos`, or `ubuntu` (linux flavor). Not guaranteed to be any of those" + }, + { + "valueName": "platformDescription", + "valueType": "String", + "valueDescription": "Description of the platform, like `Windows 10 (10.0)`" + } + ] + }, + { + "description": "Gets statistics about OBS, obs-websocket, and the current session.", + "requestType": "GetStats", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [], + "responseFields": [ + { + "valueName": "cpuUsage", + "valueType": "Number", + "valueDescription": "Current CPU usage in percent" + }, + { + "valueName": "memoryUsage", + "valueType": "Number", + "valueDescription": "Amount of memory in MB currently being used by OBS" + }, + { + "valueName": "availableDiskSpace", + "valueType": "Number", + "valueDescription": "Available disk space on the device being used for recording storage" + }, + { + "valueName": "activeFps", + "valueType": "Number", + "valueDescription": "Current FPS being rendered" + }, + { + "valueName": "averageFrameRenderTime", + "valueType": "Number", + "valueDescription": "Average time in milliseconds that OBS is taking to render a frame" + }, + { + "valueName": "renderSkippedFrames", + "valueType": "Number", + "valueDescription": "Number of frames skipped by OBS in the render thread" + }, + { + "valueName": "renderTotalFrames", + "valueType": "Number", + "valueDescription": "Total number of frames outputted by the render thread" + }, + { + "valueName": "outputSkippedFrames", + "valueType": "Number", + "valueDescription": "Number of frames skipped by OBS in the output thread" + }, + { + "valueName": "outputTotalFrames", + "valueType": "Number", + "valueDescription": "Total number of frames outputted by the output thread" + }, + { + "valueName": "webSocketSessionIncomingMessages", + "valueType": "Number", + "valueDescription": "Total number of messages received by obs-websocket from the client" + }, + { + "valueName": "webSocketSessionOutgoingMessages", + "valueType": "Number", + "valueDescription": "Total number of messages sent by obs-websocket to the client" + } + ] + }, + { + "description": "Broadcasts a `CustomEvent` to all WebSocket clients. Receivers are clients which are identified and subscribed.", + "requestType": "BroadcastCustomEvent", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "eventData", + "valueType": "Object", + "valueDescription": "Data payload to emit to all receivers", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Call a request registered to a vendor.\n\nA vendor is a unique name registered by a third-party plugin or script, which allows for custom requests and events to be added to obs-websocket.\nIf a plugin or script implements vendor requests or events, documentation is expected to be provided with them.", + "requestType": "CallVendorRequest", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "vendorName", + "valueType": "String", + "valueDescription": "Name of the vendor to use", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "requestType", + "valueType": "String", + "valueDescription": "The request type to call", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "requestData", + "valueType": "Object", + "valueDescription": "Object containing appropriate request data", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "{}" + } + ], + "responseFields": [ + { + "valueName": "vendorName", + "valueType": "String", + "valueDescription": "Echoed of `vendorName`" + }, + { + "valueName": "requestType", + "valueType": "String", + "valueDescription": "Echoed of `requestType`" + }, + { + "valueName": "responseData", + "valueType": "Object", + "valueDescription": "Object containing appropriate response data. {} if request does not provide any response data" + } + ] + }, + { + "description": "Gets an array of all hotkey names in OBS", + "requestType": "GetHotkeyList", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [], + "responseFields": [ + { + "valueName": "hotkeys", + "valueType": "Array", + "valueDescription": "Array of hotkey names" + } + ] + }, + { + "description": "Triggers a hotkey using its name. See `GetHotkeyList`", + "requestType": "TriggerHotkeyByName", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "hotkeyName", + "valueType": "String", + "valueDescription": "Name of the hotkey to trigger", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Triggers a hotkey using a sequence of keys.", + "requestType": "TriggerHotkeyByKeySequence", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "keyId", + "valueType": "String", + "valueDescription": "The OBS key ID to use. See https://github.com/obsproject/obs-studio/blob/master/libobs/obs-hotkeys.h", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + }, + { + "valueName": "keyModifiers", + "valueType": "Object", + "valueDescription": "Object containing key modifiers to apply", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Ignored" + }, + { + "valueName": "keyModifiers.shift", + "valueType": "Boolean", + "valueDescription": "Press Shift", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + }, + { + "valueName": "keyModifiers.control", + "valueType": "Boolean", + "valueDescription": "Press CTRL", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + }, + { + "valueName": "keyModifiers.alt", + "valueType": "Boolean", + "valueDescription": "Press ALT", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + }, + { + "valueName": "keyModifiers.command", + "valueType": "Boolean", + "valueDescription": "Press CMD (Mac)", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + } + ], + "responseFields": [] + }, + { + "description": "Sleeps for a time duration or number of frames. Only available in request batches with types `SERIAL_REALTIME` or `SERIAL_FRAME`.", + "requestType": "Sleep", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "sleepMillis", + "valueType": "Number", + "valueDescription": "Number of milliseconds to sleep for (if `SERIAL_REALTIME` mode)", + "valueRestrictions": ">= 0, <= 50000", + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sleepFrames", + "valueType": "Number", + "valueDescription": "Number of frames to sleep for (if `SERIAL_FRAME` mode)", + "valueRestrictions": ">= 0, <= 10000", + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all inputs in OBS.", + "requestType": "GetInputList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "Restrict the array to only inputs of the specified kind", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "All kinds included" + } + ], + "responseFields": [ + { + "valueName": "inputs", + "valueType": "Array", + "valueDescription": "Array of inputs" + } + ] + }, + { + "description": "Gets an array of all available input kinds in OBS.", + "requestType": "GetInputKindList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "unversioned", + "valueType": "Boolean", + "valueDescription": "True == Return all kinds as unversioned, False == Return with version suffixes (if available)", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "false" + } + ], + "responseFields": [ + { + "valueName": "inputKinds", + "valueType": "Array", + "valueDescription": "Array of input kinds" + } + ] + }, + { + "description": "Gets the names of all special inputs.", + "requestType": "GetSpecialInputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "desktop1", + "valueType": "String", + "valueDescription": "Name of the Desktop Audio input" + }, + { + "valueName": "desktop2", + "valueType": "String", + "valueDescription": "Name of the Desktop Audio 2 input" + }, + { + "valueName": "mic1", + "valueType": "String", + "valueDescription": "Name of the Mic/Auxiliary Audio input" + }, + { + "valueName": "mic2", + "valueType": "String", + "valueDescription": "Name of the Mic/Auxiliary Audio 2 input" + }, + { + "valueName": "mic3", + "valueType": "String", + "valueDescription": "Name of the Mic/Auxiliary Audio 3 input" + }, + { + "valueName": "mic4", + "valueType": "String", + "valueDescription": "Name of the Mic/Auxiliary Audio 4 input" + } + ] + }, + { + "description": "Creates a new input, adding it as a scene item to the specified scene.", + "requestType": "CreateInput", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to add the input to as a scene item", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the new input to created", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "The kind of input to be created", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputSettings", + "valueType": "Object", + "valueDescription": "Settings object to initialize the input with", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Default settings used" + }, + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "Whether to set the created scene item to enabled or disabled", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "True" + } + ], + "responseFields": [ + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "ID of the newly created scene item" + } + ] + }, + { + "description": "Removes an existing input.\n\nNote: Will immediately remove all associated scene items.", + "requestType": "RemoveInput", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to remove", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the name of an input (rename).", + "requestType": "SetInputName", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Current input name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "newInputName", + "valueType": "String", + "valueDescription": "New name for the input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the default settings for an input kind.", + "requestType": "GetInputDefaultSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "Input kind to get the default settings for", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "defaultInputSettings", + "valueType": "Object", + "valueDescription": "Object of default settings for the input kind" + } + ] + }, + { + "description": "Gets the settings of an input.\n\nNote: Does not include defaults. To create the entire settings object, overlay `inputSettings` over the `defaultInputSettings` provided by `GetInputDefaultSettings`.", + "requestType": "GetInputSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the settings of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "inputSettings", + "valueType": "Object", + "valueDescription": "Object of settings for the input" + }, + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "The kind of the input" + } + ] + }, + { + "description": "Sets the settings of an input.", + "requestType": "SetInputSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the settings of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputSettings", + "valueType": "Object", + "valueDescription": "Object of settings to apply", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "overlay", + "valueType": "Boolean", + "valueDescription": "True == apply the settings on top of existing ones, False == reset the input to its defaults, then apply settings.", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "true" + } + ], + "responseFields": [] + }, + { + "description": "Gets the audio mute state of an input.", + "requestType": "GetInputMute", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of input to get the mute state of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "inputMuted", + "valueType": "Boolean", + "valueDescription": "Whether the input is muted" + } + ] + }, + { + "description": "Sets the audio mute state of an input.", + "requestType": "SetInputMute", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the mute state of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputMuted", + "valueType": "Boolean", + "valueDescription": "Whether to mute the input or not", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Toggles the audio mute state of an input.", + "requestType": "ToggleInputMute", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to toggle the mute state of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "inputMuted", + "valueType": "Boolean", + "valueDescription": "Whether the input has been muted or unmuted" + } + ] + }, + { + "description": "Gets the current volume setting of an input.", + "requestType": "GetInputVolume", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the volume of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "inputVolumeMul", + "valueType": "Number", + "valueDescription": "Volume setting in mul" + }, + { + "valueName": "inputVolumeDb", + "valueType": "Number", + "valueDescription": "Volume setting in dB" + } + ] + }, + { + "description": "Sets the volume setting of an input.", + "requestType": "SetInputVolume", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the volume of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputVolumeMul", + "valueType": "Number", + "valueDescription": "Volume setting in mul", + "valueRestrictions": ">= 0, <= 20", + "valueOptional": true, + "valueOptionalBehavior": "`inputVolumeDb` should be specified" + }, + { + "valueName": "inputVolumeDb", + "valueType": "Number", + "valueDescription": "Volume setting in dB", + "valueRestrictions": ">= -100, <= 26", + "valueOptional": true, + "valueOptionalBehavior": "`inputVolumeMul` should be specified" + } + ], + "responseFields": [] + }, + { + "description": "Gets the audio balance of an input.", + "requestType": "GetInputAudioBalance", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the audio balance of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "inputAudioBalance", + "valueType": "Number", + "valueDescription": "Audio balance value from 0.0-1.0" + } + ] + }, + { + "description": "Sets the audio balance of an input.", + "requestType": "SetInputAudioBalance", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the audio balance of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputAudioBalance", + "valueType": "Number", + "valueDescription": "New audio balance value", + "valueRestrictions": ">= 0.0, <= 1.0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the audio sync offset of an input.\n\nNote: The audio sync offset can be negative too!", + "requestType": "GetInputAudioSyncOffset", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the audio sync offset of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "inputAudioSyncOffset", + "valueType": "Number", + "valueDescription": "Audio sync offset in milliseconds" + } + ] + }, + { + "description": "Sets the audio sync offset of an input.", + "requestType": "SetInputAudioSyncOffset", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the audio sync offset of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputAudioSyncOffset", + "valueType": "Number", + "valueDescription": "New audio sync offset in milliseconds", + "valueRestrictions": ">= -950, <= 20000", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the audio monitor type of an input.\n\nThe available audio monitor types are:\n\n- `OBS_MONITORING_TYPE_NONE`\n- `OBS_MONITORING_TYPE_MONITOR_ONLY`\n- `OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT`", + "requestType": "GetInputAudioMonitorType", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the audio monitor type of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "monitorType", + "valueType": "String", + "valueDescription": "Audio monitor type" + } + ] + }, + { + "description": "Sets the audio monitor type of an input.", + "requestType": "SetInputAudioMonitorType", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the audio monitor type of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "monitorType", + "valueType": "String", + "valueDescription": "Audio monitor type", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the enable state of all audio tracks of an input.", + "requestType": "GetInputAudioTracks", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "inputAudioTracks", + "valueType": "Object", + "valueDescription": "Object of audio tracks and associated enable states" + } + ] + }, + { + "description": "Sets the enable state of audio tracks of an input.", + "requestType": "SetInputAudioTracks", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputAudioTracks", + "valueType": "Object", + "valueDescription": "Track settings to apply", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the items of a list property from an input's properties.\n\nNote: Use this in cases where an input provides a dynamic, selectable list of items. For example, display capture, where it provides a list of available displays.", + "requestType": "GetInputPropertiesListPropertyItems", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "propertyName", + "valueType": "String", + "valueDescription": "Name of the list property to get the items of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "propertyItems", + "valueType": "Array", + "valueDescription": "Array of items in the list property" + } + ] + }, + { + "description": "Presses a button in the properties of an input.\n\nSome known `propertyName` values are:\n\n- `refreshnocache` - Browser source reload button\n\nNote: Use this in cases where there is a button in the properties of an input that cannot be accessed in any other way. For example, browser sources, where there is a refresh button.", + "requestType": "PressInputPropertiesButton", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "propertyName", + "valueType": "String", + "valueDescription": "Name of the button property to press", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the status of a media input.\n\nMedia States:\n\n- `OBS_MEDIA_STATE_NONE`\n- `OBS_MEDIA_STATE_PLAYING`\n- `OBS_MEDIA_STATE_OPENING`\n- `OBS_MEDIA_STATE_BUFFERING`\n- `OBS_MEDIA_STATE_PAUSED`\n- `OBS_MEDIA_STATE_STOPPED`\n- `OBS_MEDIA_STATE_ENDED`\n- `OBS_MEDIA_STATE_ERROR`", + "requestType": "GetMediaInputStatus", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the media input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "mediaState", + "valueType": "String", + "valueDescription": "State of the media input" + }, + { + "valueName": "mediaDuration", + "valueType": "Number", + "valueDescription": "Total duration of the playing media in milliseconds. `null` if not playing" + }, + { + "valueName": "mediaCursor", + "valueType": "Number", + "valueDescription": "Position of the cursor in milliseconds. `null` if not playing" + } + ] + }, + { + "description": "Sets the cursor position of a media input.\n\nThis request does not perform bounds checking of the cursor position.", + "requestType": "SetMediaInputCursor", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the media input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "mediaCursor", + "valueType": "Number", + "valueDescription": "New cursor position to set", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Offsets the current cursor position of a media input by the specified value.\n\nThis request does not perform bounds checking of the cursor position.", + "requestType": "OffsetMediaInputCursor", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the media input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "mediaCursorOffset", + "valueType": "Number", + "valueDescription": "Value to offset the current cursor position by", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Triggers an action on a media input.", + "requestType": "TriggerMediaInputAction", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the media input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "mediaAction", + "valueType": "String", + "valueDescription": "Identifier of the `ObsMediaInputAction` enum", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the status of the virtualcam output.", + "requestType": "GetVirtualCamStatus", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Toggles the state of the virtualcam output.", + "requestType": "ToggleVirtualCam", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Starts the virtualcam output.", + "requestType": "StartVirtualCam", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Stops the virtualcam output.", + "requestType": "StopVirtualCam", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Gets the status of the replay buffer output.", + "requestType": "GetReplayBufferStatus", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Toggles the state of the replay buffer output.", + "requestType": "ToggleReplayBuffer", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Starts the replay buffer output.", + "requestType": "StartReplayBuffer", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Stops the replay buffer output.", + "requestType": "StopReplayBuffer", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Saves the contents of the replay buffer output.", + "requestType": "SaveReplayBuffer", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Gets the filename of the last replay buffer save file.", + "requestType": "GetLastReplayBufferReplay", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "savedReplayPath", + "valueType": "String", + "valueDescription": "File path" + } + ] + }, + { + "description": "Gets the list of available outputs.", + "requestType": "GetOutputList", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputs", + "valueType": "Array", + "valueDescription": "Array of outputs" + } + ] + }, + { + "description": "Gets the status of an output.", + "requestType": "GetOutputStatus", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputReconnecting", + "valueType": "Boolean", + "valueDescription": "Whether the output is reconnecting" + }, + { + "valueName": "outputTimecode", + "valueType": "String", + "valueDescription": "Current formatted timecode string for the output" + }, + { + "valueName": "outputDuration", + "valueType": "Number", + "valueDescription": "Current duration in milliseconds for the output" + }, + { + "valueName": "outputCongestion", + "valueType": "Number", + "valueDescription": "Congestion of the output" + }, + { + "valueName": "outputBytes", + "valueType": "Number", + "valueDescription": "Number of bytes sent by the output" + }, + { + "valueName": "outputSkippedFrames", + "valueType": "Number", + "valueDescription": "Number of frames skipped by the output's process" + }, + { + "valueName": "outputTotalFrames", + "valueType": "Number", + "valueDescription": "Total number of frames delivered by the output's process" + } + ] + }, + { + "description": "Toggles the status of an output.", + "requestType": "ToggleOutput", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Starts an output.", + "requestType": "StartOutput", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Stops an output.", + "requestType": "StopOutput", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the settings of an output.", + "requestType": "GetOutputSettings", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "outputSettings", + "valueType": "Object", + "valueDescription": "Output settings" + } + ] + }, + { + "description": "Sets the settings of an output.", + "requestType": "SetOutputSettings", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "outputSettings", + "valueType": "Object", + "valueDescription": "Output settings", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the status of the record output.", + "requestType": "GetRecordStatus", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputPaused", + "valueType": "Boolean", + "valueDescription": "Whether the output is paused" + }, + { + "valueName": "outputTimecode", + "valueType": "String", + "valueDescription": "Current formatted timecode string for the output" + }, + { + "valueName": "outputDuration", + "valueType": "Number", + "valueDescription": "Current duration in milliseconds for the output" + }, + { + "valueName": "outputBytes", + "valueType": "Number", + "valueDescription": "Number of bytes sent by the output" + } + ] + }, + { + "description": "Toggles the status of the record output.", + "requestType": "ToggleRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Starts the record output.", + "requestType": "StartRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Stops the record output.", + "requestType": "StopRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputPath", + "valueType": "String", + "valueDescription": "File name for the saved recording" + } + ] + }, + { + "description": "Toggles pause on the record output.", + "requestType": "ToggleRecordPause", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Pauses the record output.", + "requestType": "PauseRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Resumes the record output.", + "requestType": "ResumeRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Gets a list of all scene items in a scene.\n\nScenes only", + "requestType": "GetSceneItemList", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to get the items of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItems", + "valueType": "Array", + "valueDescription": "Array of scene items in the scene" + } + ] + }, + { + "description": "Basically GetSceneItemList, but for groups.\n\nUsing groups at all in OBS is discouraged, as they are very broken under the hood. Please use nested scenes instead.\n\nGroups only", + "requestType": "GetGroupSceneItemList", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the group to get the items of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItems", + "valueType": "Array", + "valueDescription": "Array of scene items in the group" + } + ] + }, + { + "description": "Searches a scene for a source, and returns its id.\n\nScenes and Groups", + "requestType": "GetSceneItemId", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene or group to search in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to find", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "searchOffset", + "valueType": "Number", + "valueDescription": "Number of matches to skip during search. >= 0 means first forward. -1 means last (top) item", + "valueRestrictions": ">= -1", + "valueOptional": true, + "valueOptionalBehavior": "0" + } + ], + "responseFields": [ + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + } + ] + }, + { + "description": "Creates a new scene item using a source.\n\nScenes only", + "requestType": "CreateSceneItem", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to create the new item in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to add to the scene", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "Enable state to apply to the scene item on creation", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "True" + } + ], + "responseFields": [ + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + } + ] + }, + { + "description": "Removes a scene item from a scene.\n\nScenes only", + "requestType": "RemoveSceneItem", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Duplicates a scene item, copying all transform and crop info.\n\nScenes only", + "requestType": "DuplicateSceneItem", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "destinationSceneName", + "valueType": "String", + "valueDescription": "Name of the scene to create the duplicated item in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "`sceneName` is assumed" + } + ], + "responseFields": [ + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the duplicated scene item" + } + ] + }, + { + "description": "Gets the transform and crop info of a scene item.\n\nScenes and Groups", + "requestType": "GetSceneItemTransform", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemTransform", + "valueType": "Object", + "valueDescription": "Object containing scene item transform info" + } + ] + }, + { + "description": "Sets the transform and crop info of a scene item.", + "requestType": "SetSceneItemTransform", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemTransform", + "valueType": "Object", + "valueDescription": "Object containing scene item transform info to update", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the enable state of a scene item.\n\nScenes and Groups", + "requestType": "GetSceneItemEnabled", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "Whether the scene item is enabled. `true` for enabled, `false` for disabled" + } + ] + }, + { + "description": "Sets the enable state of a scene item.\n\nScenes and Groups", + "requestType": "SetSceneItemEnabled", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "New enable state of the scene item", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the lock state of a scene item.\n\nScenes and Groups", + "requestType": "GetSceneItemLocked", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemLocked", + "valueType": "Boolean", + "valueDescription": "Whether the scene item is locked. `true` for locked, `false` for unlocked" + } + ] + }, + { + "description": "Sets the lock state of a scene item.\n\nScenes and Group", + "requestType": "SetSceneItemLocked", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemLocked", + "valueType": "Boolean", + "valueDescription": "New lock state of the scene item", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the index position of a scene item in a scene.\n\nAn index of 0 is at the bottom of the source list in the UI.\n\nScenes and Groups", + "requestType": "GetSceneItemIndex", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemIndex", + "valueType": "Number", + "valueDescription": "Index position of the scene item" + } + ] + }, + { + "description": "Sets the index position of a scene item in a scene.\n\nScenes and Groups", + "requestType": "SetSceneItemIndex", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemIndex", + "valueType": "Number", + "valueDescription": "New index position of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the blend mode of a scene item.\n\nBlend modes:\n\n- `OBS_BLEND_NORMAL`\n- `OBS_BLEND_ADDITIVE`\n- `OBS_BLEND_SUBTRACT`\n- `OBS_BLEND_SCREEN`\n- `OBS_BLEND_MULTIPLY`\n- `OBS_BLEND_LIGHTEN`\n- `OBS_BLEND_DARKEN`\n\nScenes and Groups", + "requestType": "GetSceneItemBlendMode", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemBlendMode", + "valueType": "String", + "valueDescription": "Current blend mode" + } + ] + }, + { + "description": "Sets the blend mode of a scene item.\n\nScenes and Groups", + "requestType": "SetSceneItemBlendMode", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemBlendMode", + "valueType": "String", + "valueDescription": "New blend mode", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all scenes in OBS.", + "requestType": "GetSceneList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [], + "responseFields": [ + { + "valueName": "currentProgramSceneName", + "valueType": "String", + "valueDescription": "Current program scene" + }, + { + "valueName": "currentPreviewSceneName", + "valueType": "String", + "valueDescription": "Current preview scene. `null` if not in studio mode" + }, + { + "valueName": "scenes", + "valueType": "Array", + "valueDescription": "Array of scenes" + } + ] + }, + { + "description": "Gets an array of all groups in OBS.\n\nGroups in OBS are actually scenes, but renamed and modified. In obs-websocket, we treat them as scenes where we can.", + "requestType": "GetGroupList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [], + "responseFields": [ + { + "valueName": "groups", + "valueType": "Array", + "valueDescription": "Array of group names" + } + ] + }, + { + "description": "Gets the current program scene.", + "requestType": "GetCurrentProgramScene", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [], + "responseFields": [ + { + "valueName": "currentProgramSceneName", + "valueType": "String", + "valueDescription": "Current program scene" + } + ] + }, + { + "description": "Sets the current program scene.", + "requestType": "SetCurrentProgramScene", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Scene to set as the current program scene", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the current preview scene.\n\nOnly available when studio mode is enabled.", + "requestType": "GetCurrentPreviewScene", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [], + "responseFields": [ + { + "valueName": "currentPreviewSceneName", + "valueType": "String", + "valueDescription": "Current preview scene" + } + ] + }, + { + "description": "Sets the current preview scene.\n\nOnly available when studio mode is enabled.", + "requestType": "SetCurrentPreviewScene", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Scene to set as the current preview scene", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Creates a new scene in OBS.", + "requestType": "CreateScene", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name for the new scene", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Removes a scene from OBS.", + "requestType": "RemoveScene", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to remove", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the name of a scene (rename).", + "requestType": "SetSceneName", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to be renamed", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "newSceneName", + "valueType": "String", + "valueDescription": "New name for the scene", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the scene transition overridden for a scene.", + "requestType": "GetSceneSceneTransitionOverride", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the overridden scene transition, else `null`" + }, + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Duration of the overridden scene transition, else `null`" + } + ] + }, + { + "description": "Gets the scene transition overridden for a scene.", + "requestType": "SetSceneSceneTransitionOverride", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the scene transition to use as override. Specify `null` to remove", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unchanged" + }, + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Duration to use for any overridden transition. Specify `null` to remove", + "valueRestrictions": ">= 50, <= 20000", + "valueOptional": true, + "valueOptionalBehavior": "Unchanged" + } + ], + "responseFields": [] + }, + { + "description": "Gets the active and show state of a source.\n\n**Compatible with inputs and scenes.**", + "requestType": "GetSourceActive", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "sources", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to get the active state of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "videoActive", + "valueType": "Boolean", + "valueDescription": "Whether the source is showing in Program" + }, + { + "valueName": "videoShowing", + "valueType": "Boolean", + "valueDescription": "Whether the source is showing in the UI (Preview, Projector, Properties)" + } + ] + }, + { + "description": "Gets a Base64-encoded screenshot of a source.\n\nThe `imageWidth` and `imageHeight` parameters are treated as \"scale to inner\", meaning the smallest ratio will be used and the aspect ratio of the original resolution is kept.\nIf `imageWidth` and `imageHeight` are not specified, the compressed image will use the full resolution of the source.\n\n**Compatible with inputs and scenes.**", + "requestType": "GetSourceScreenshot", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "sources", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to take a screenshot of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "imageFormat", + "valueType": "String", + "valueDescription": "Image compression format to use. Use `GetVersion` to get compatible image formats", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "imageWidth", + "valueType": "Number", + "valueDescription": "Width to scale the screenshot to", + "valueRestrictions": ">= 8, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Source value is used" + }, + { + "valueName": "imageHeight", + "valueType": "Number", + "valueDescription": "Height to scale the screenshot to", + "valueRestrictions": ">= 8, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Source value is used" + }, + { + "valueName": "imageCompressionQuality", + "valueType": "Number", + "valueDescription": "Compression quality to use. 0 for high compression, 100 for uncompressed. -1 to use \"default\" (whatever that means, idk)", + "valueRestrictions": ">= -1, <= 100", + "valueOptional": true, + "valueOptionalBehavior": "-1" + } + ], + "responseFields": [ + { + "valueName": "imageData", + "valueType": "String", + "valueDescription": "Base64-encoded screenshot" + } + ] + }, + { + "description": "Saves a screenshot of a source to the filesystem.\n\nThe `imageWidth` and `imageHeight` parameters are treated as \"scale to inner\", meaning the smallest ratio will be used and the aspect ratio of the original resolution is kept.\nIf `imageWidth` and `imageHeight` are not specified, the compressed image will use the full resolution of the source.\n\n**Compatible with inputs and scenes.**", + "requestType": "SaveSourceScreenshot", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "sources", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to take a screenshot of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "imageFormat", + "valueType": "String", + "valueDescription": "Image compression format to use. Use `GetVersion` to get compatible image formats", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "imageFilePath", + "valueType": "String", + "valueDescription": "Path to save the screenshot file to. Eg. `C:\\Users\\user\\Desktop\\screenshot.png`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "imageWidth", + "valueType": "Number", + "valueDescription": "Width to scale the screenshot to", + "valueRestrictions": ">= 8, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Source value is used" + }, + { + "valueName": "imageHeight", + "valueType": "Number", + "valueDescription": "Height to scale the screenshot to", + "valueRestrictions": ">= 8, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Source value is used" + }, + { + "valueName": "imageCompressionQuality", + "valueType": "Number", + "valueDescription": "Compression quality to use. 0 for high compression, 100 for uncompressed. -1 to use \"default\" (whatever that means, idk)", + "valueRestrictions": ">= -1, <= 100", + "valueOptional": true, + "valueOptionalBehavior": "-1" + } + ], + "responseFields": [ + { + "valueName": "imageData", + "valueType": "String", + "valueDescription": "Base64-encoded screenshot" + } + ] + }, + { + "description": "Gets the status of the stream output.", + "requestType": "GetStreamStatus", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputReconnecting", + "valueType": "Boolean", + "valueDescription": "Whether the output is currently reconnecting" + }, + { + "valueName": "outputTimecode", + "valueType": "String", + "valueDescription": "Current formatted timecode string for the output" + }, + { + "valueName": "outputDuration", + "valueType": "Number", + "valueDescription": "Current duration in milliseconds for the output" + }, + { + "valueName": "outputCongestion", + "valueType": "Number", + "valueDescription": "Congestion of the output" + }, + { + "valueName": "outputBytes", + "valueType": "Number", + "valueDescription": "Number of bytes sent by the output" + }, + { + "valueName": "outputSkippedFrames", + "valueType": "Number", + "valueDescription": "Number of frames skipped by the output's process" + }, + { + "valueName": "outputTotalFrames", + "valueType": "Number", + "valueDescription": "Total number of frames delivered by the output's process" + } + ] + }, + { + "description": "Toggles the status of the stream output.", + "requestType": "ToggleStream", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "New state of the stream output" + } + ] + }, + { + "description": "Starts the stream output.", + "requestType": "StartStream", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Stops the stream output.", + "requestType": "StopStream", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Sends CEA-608 caption text over the stream output.", + "requestType": "SendStreamCaption", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [ + { + "valueName": "captionText", + "valueType": "String", + "valueDescription": "Caption text", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all available transition kinds.\n\nSimilar to `GetInputKindList`", + "requestType": "GetTransitionKindList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [ + { + "valueName": "transitionKinds", + "valueType": "Array", + "valueDescription": "Array of transition kinds" + } + ] + }, + { + "description": "Gets an array of all scene transitions in OBS.", + "requestType": "GetSceneTransitionList", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [ + { + "valueName": "currentSceneTransitionName", + "valueType": "String", + "valueDescription": "Name of the current scene transition. Can be null" + }, + { + "valueName": "currentSceneTransitionKind", + "valueType": "String", + "valueDescription": "Kind of the current scene transition. Can be null" + }, + { + "valueName": "transitions", + "valueType": "Array", + "valueDescription": "Array of transitions" + } + ] + }, + { + "description": "Gets information about the current scene transition.", + "requestType": "GetCurrentSceneTransition", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the transition" + }, + { + "valueName": "transitionKind", + "valueType": "String", + "valueDescription": "Kind of the transition" + }, + { + "valueName": "transitionFixed", + "valueType": "Boolean", + "valueDescription": "Whether the transition uses a fixed (unconfigurable) duration" + }, + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Configured transition duration in milliseconds. `null` if transition is fixed" + }, + { + "valueName": "transitionConfigurable", + "valueType": "Boolean", + "valueDescription": "Whether the transition supports being configured" + }, + { + "valueName": "transitionSettings", + "valueType": "Object", + "valueDescription": "Object of settings for the transition. `null` if transition is not configurable" + } + ] + }, + { + "description": "Sets the current scene transition.\n\nSmall note: While the namespace of scene transitions is generally unique, that uniqueness is not a guarantee as it is with other resources like inputs.", + "requestType": "SetCurrentSceneTransition", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the transition to make active", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the duration of the current scene transition, if it is not fixed.", + "requestType": "SetCurrentSceneTransitionDuration", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [ + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Duration in milliseconds", + "valueRestrictions": ">= 50, <= 20000", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the settings of the current scene transition.", + "requestType": "SetCurrentSceneTransitionSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [ + { + "valueName": "transitionSettings", + "valueType": "Object", + "valueDescription": "Settings object to apply to the transition. Can be `{}`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "overlay", + "valueType": "Boolean", + "valueDescription": "Whether to overlay over the current settings or replace them", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "true" + } + ], + "responseFields": [] + }, + { + "description": "Gets the cursor position of the current scene transition.\n\nNote: `transitionCursor` will return 1.0 when the transition is inactive.", + "requestType": "GetCurrentSceneTransitionCursor", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [ + { + "valueName": "transitionCursor", + "valueType": "Number", + "valueDescription": "Cursor position, between 0.0 and 1.0" + } + ] + }, + { + "description": "Triggers the current scene transition. Same functionality as the `Transition` button in studio mode.", + "requestType": "TriggerStudioModeTransition", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Sets the position of the TBar.\n\n**Very important note**: This will be deprecated and replaced in a future version of obs-websocket.", + "requestType": "SetTBarPosition", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [ + { + "valueName": "position", + "valueType": "Number", + "valueDescription": "New position", + "valueRestrictions": ">= 0.0, <= 1.0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "release", + "valueType": "Boolean", + "valueDescription": "Whether to release the TBar. Only set `false` if you know that you will be sending another position update", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "`true`" + } + ], + "responseFields": [] + }, + { + "description": "Gets whether studio is enabled.", + "requestType": "GetStudioModeEnabled", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [], + "responseFields": [ + { + "valueName": "studioModeEnabled", + "valueType": "Boolean", + "valueDescription": "Whether studio mode is enabled" + } + ] + }, + { + "description": "Enables or disables studio mode", + "requestType": "SetStudioModeEnabled", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "studioModeEnabled", + "valueType": "Boolean", + "valueDescription": "True == Enabled, False == Disabled", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Opens the properties dialog of an input.", + "requestType": "OpenInputPropertiesDialog", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to open the dialog of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Opens the filters dialog of an input.", + "requestType": "OpenInputFiltersDialog", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to open the dialog of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Opens the interact dialog of an input.", + "requestType": "OpenInputInteractDialog", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to open the dialog of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets a list of connected monitors and information about them.", + "requestType": "GetMonitorList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [], + "responseFields": [ + { + "valueName": "monitors", + "valueType": "Array", + "valueDescription": "a list of detected monitors with some information" + } + ] + }, + { + "description": "Opens a projector for a specific output video mix.\n\nMix types:\n\n- `OBS_WEBSOCKET_VIDEO_MIX_TYPE_PREVIEW`\n- `OBS_WEBSOCKET_VIDEO_MIX_TYPE_PROGRAM`\n- `OBS_WEBSOCKET_VIDEO_MIX_TYPE_MULTIVIEW`\n\nNote: This request serves to provide feature parity with 4.x. It is very likely to be changed/deprecated in a future release.", + "requestType": "OpenVideoMixProjector", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "videoMixType", + "valueType": "String", + "valueDescription": "Type of mix to open", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "monitorIndex", + "valueType": "Number", + "valueDescription": "Monitor index, use `GetMonitorList` to obtain index", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "-1: Opens projector in windowed mode" + }, + { + "valueName": "projectorGeometry", + "valueType": "String", + "valueDescription": "Size/Position data for a windowed projector, in Qt Base64 encoded format. Mutually exclusive with `monitorIndex`", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "N/A" + } + ], + "responseFields": [] + }, + { + "description": "Opens a projector for a source.\n\nNote: This request serves to provide feature parity with 4.x. It is very likely to be changed/deprecated in a future release.", + "requestType": "OpenSourceProjector", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to open a projector for", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "monitorIndex", + "valueType": "Number", + "valueDescription": "Monitor index, use `GetMonitorList` to obtain index", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "-1: Opens projector in windowed mode" + }, + { + "valueName": "projectorGeometry", + "valueType": "String", + "valueDescription": "Size/Position data for a windowed projector, in Qt Base64 encoded format. Mutually exclusive with `monitorIndex`", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "N/A" + } + ], + "responseFields": [] + } + ], + "events": [ + { + "description": "The current scene collection has begun changing.\n\nNote: We recommend using this event to trigger a pause of all polling requests, as performing any requests during a\nscene collection change is considered undefined behavior and can cause crashes!", + "eventType": "CurrentSceneCollectionChanging", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "sceneCollectionName", + "valueType": "String", + "valueDescription": "Name of the current scene collection" + } + ] + }, + { + "description": "The current scene collection has changed.\n\nNote: If polling has been paused during `CurrentSceneCollectionChanging`, this is the que to restart polling.", + "eventType": "CurrentSceneCollectionChanged", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "sceneCollectionName", + "valueType": "String", + "valueDescription": "Name of the new scene collection" + } + ] + }, + { + "description": "The scene collection list has changed.", + "eventType": "SceneCollectionListChanged", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "sceneCollections", + "valueType": "Array", + "valueDescription": "Updated list of scene collections" + } + ] + }, + { + "description": "The current profile has begun changing.", + "eventType": "CurrentProfileChanging", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name of the current profile" + } + ] + }, + { + "description": "The current profile has changed.", + "eventType": "CurrentProfileChanged", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name of the new profile" + } + ] + }, + { + "description": "The profile list has changed.", + "eventType": "ProfileListChanged", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "profiles", + "valueType": "Array", + "valueDescription": "Updated list of profiles" + } + ] + }, + { + "description": "A source's filter list has been reindexed.", + "eventType": "SourceFilterListReindexed", + "eventSubscription": "Filters", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source" + }, + { + "valueName": "filters", + "valueType": "Array", + "valueDescription": "Array of filter objects" + } + ] + }, + { + "description": "A filter has been added to a source.", + "eventType": "SourceFilterCreated", + "eventSubscription": "Filters", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter was added to" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter" + }, + { + "valueName": "filterKind", + "valueType": "String", + "valueDescription": "The kind of the filter" + }, + { + "valueName": "filterIndex", + "valueType": "Number", + "valueDescription": "Index position of the filter" + }, + { + "valueName": "filterSettings", + "valueType": "Object", + "valueDescription": "The settings configured to the filter when it was created" + }, + { + "valueName": "defaultFilterSettings", + "valueType": "Object", + "valueDescription": "The default settings for the filter" + } + ] + }, + { + "description": "A filter has been removed from a source.", + "eventType": "SourceFilterRemoved", + "eventSubscription": "Filters", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter was on" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter" + } + ] + }, + { + "description": "The name of a source filter has changed.", + "eventType": "SourceFilterNameChanged", + "eventSubscription": "Filters", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "The source the filter is on" + }, + { + "valueName": "oldFilterName", + "valueType": "String", + "valueDescription": "Old name of the filter" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "New name of the filter" + } + ] + }, + { + "description": "A source filter's enable state has changed.", + "eventType": "SourceFilterEnableStateChanged", + "eventSubscription": "Filters", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter" + }, + { + "valueName": "filterEnabled", + "valueType": "Boolean", + "valueDescription": "Whether the filter is enabled" + } + ] + }, + { + "description": "OBS has begun the shutdown process.", + "eventType": "ExitStarted", + "eventSubscription": "General", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "dataFields": [] + }, + { + "description": "An input has been created.", + "eventType": "InputCreated", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "The kind of the input" + }, + { + "valueName": "unversionedInputKind", + "valueType": "String", + "valueDescription": "The unversioned kind of input (aka no `_v2` stuff)" + }, + { + "valueName": "inputSettings", + "valueType": "Object", + "valueDescription": "The settings configured to the input when it was created" + }, + { + "valueName": "defaultInputSettings", + "valueType": "Object", + "valueDescription": "The default settings for the input" + } + ] + }, + { + "description": "An input has been removed.", + "eventType": "InputRemoved", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + } + ] + }, + { + "description": "The name of an input has changed.", + "eventType": "InputNameChanged", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "oldInputName", + "valueType": "String", + "valueDescription": "Old name of the input" + }, + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "New name of the input" + } + ] + }, + { + "description": "An input's active state has changed.\n\nWhen an input is active, it means it's being shown by the program feed.", + "eventType": "InputActiveStateChanged", + "eventSubscription": "InputActiveStateChanged", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "videoActive", + "valueType": "Boolean", + "valueDescription": "Whether the input is active" + } + ] + }, + { + "description": "An input's show state has changed.\n\nWhen an input is showing, it means it's being shown by the preview or a dialog.", + "eventType": "InputShowStateChanged", + "eventSubscription": "InputShowStateChanged", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "videoShowing", + "valueType": "Boolean", + "valueDescription": "Whether the input is showing" + } + ] + }, + { + "description": "An input's mute state has changed.", + "eventType": "InputMuteStateChanged", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputMuted", + "valueType": "Boolean", + "valueDescription": "Whether the input is muted" + } + ] + }, + { + "description": "An input's volume level has changed.", + "eventType": "InputVolumeChanged", + "eventSubscription": "Inputs", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputVolumeMul", + "valueType": "Number", + "valueDescription": "New volume level multiplier" + }, + { + "valueName": "inputVolumeDb", + "valueType": "Number", + "valueDescription": "New volume level in dB" + } + ] + }, + { + "description": "The audio balance value of an input has changed.", + "eventType": "InputAudioBalanceChanged", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the affected input" + }, + { + "valueName": "inputAudioBalance", + "valueType": "Number", + "valueDescription": "New audio balance value of the input" + } + ] + }, + { + "description": "The sync offset of an input has changed.", + "eventType": "InputAudioSyncOffsetChanged", + "eventSubscription": "Inputs", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputAudioSyncOffset", + "valueType": "Number", + "valueDescription": "New sync offset in milliseconds" + } + ] + }, + { + "description": "The audio tracks of an input have changed.", + "eventType": "InputAudioTracksChanged", + "eventSubscription": "Inputs", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputAudioTracks", + "valueType": "Object", + "valueDescription": "Object of audio tracks along with their associated enable states" + } + ] + }, + { + "description": "The monitor type of an input has changed.\n\nAvailable types are:\n\n- `OBS_MONITORING_TYPE_NONE`\n- `OBS_MONITORING_TYPE_MONITOR_ONLY`\n- `OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT`", + "eventType": "InputAudioMonitorTypeChanged", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "monitorType", + "valueType": "String", + "valueDescription": "New monitor type of the input" + } + ] + }, + { + "description": "A high-volume event providing volume levels of all active inputs every 50 milliseconds.", + "eventType": "InputVolumeMeters", + "eventSubscription": "InputVolumeMeters", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputs", + "valueType": "Array", + "valueDescription": "Array of active inputs with their associated volume levels" + } + ] + }, + { + "description": "A media input has started playing.", + "eventType": "MediaInputPlaybackStarted", + "eventSubscription": "MediaInputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + } + ] + }, + { + "description": "A media input has finished playing.", + "eventType": "MediaInputPlaybackEnded", + "eventSubscription": "MediaInputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + } + ] + }, + { + "description": "An action has been performed on an input.", + "eventType": "MediaInputActionTriggered", + "eventSubscription": "MediaInputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "mediaAction", + "valueType": "String", + "valueDescription": "Action performed on the input. See `ObsMediaInputAction` enum" + } + ] + }, + { + "description": "The state of the stream output has changed.", + "eventType": "StreamStateChanged", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputState", + "valueType": "String", + "valueDescription": "The specific state of the output" + } + ] + }, + { + "description": "The state of the record output has changed.", + "eventType": "RecordStateChanged", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputState", + "valueType": "String", + "valueDescription": "The specific state of the output" + }, + { + "valueName": "outputPath", + "valueType": "String", + "valueDescription": "File name for the saved recording, if record stopped. `null` otherwise" + } + ] + }, + { + "description": "The state of the replay buffer output has changed.", + "eventType": "ReplayBufferStateChanged", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputState", + "valueType": "String", + "valueDescription": "The specific state of the output" + } + ] + }, + { + "description": "The state of the virtualcam output has changed.", + "eventType": "VirtualcamStateChanged", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputState", + "valueType": "String", + "valueDescription": "The specific state of the output" + } + ] + }, + { + "description": "The replay buffer has been saved.", + "eventType": "ReplayBufferSaved", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "savedReplayPath", + "valueType": "String", + "valueDescription": "Path of the saved replay file" + } + ] + }, + { + "description": "A scene item has been created.", + "eventType": "SceneItemCreated", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item was added to" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the underlying source (input/scene)" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + }, + { + "valueName": "sceneItemIndex", + "valueType": "Number", + "valueDescription": "Index position of the item" + } + ] + }, + { + "description": "A scene item has been removed.\n\nThis event is not emitted when the scene the item is in is removed.", + "eventType": "SceneItemRemoved", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item was removed from" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the underlying source (input/scene)" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + } + ] + }, + { + "description": "A scene's item list has been reindexed.", + "eventType": "SceneItemListReindexed", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene" + }, + { + "valueName": "sceneItems", + "valueType": "Array", + "valueDescription": "Array of scene item objects" + } + ] + }, + { + "description": "A scene item's enable state has changed.", + "eventType": "SceneItemEnableStateChanged", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + }, + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "Whether the scene item is enabled (visible)" + } + ] + }, + { + "description": "A scene item's lock state has changed.", + "eventType": "SceneItemLockStateChanged", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + }, + { + "valueName": "sceneItemLocked", + "valueType": "Boolean", + "valueDescription": "Whether the scene item is locked" + } + ] + }, + { + "description": "A scene item has been selected in the Ui.", + "eventType": "SceneItemSelected", + "eventSubscription": "SceneItems", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + } + ] + }, + { + "description": "The transform/crop of a scene item has changed.", + "eventType": "SceneItemTransformChanged", + "eventSubscription": "SceneItemTransformChanged", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "The name of the scene the item is in" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + }, + { + "valueName": "sceneItemTransform", + "valueType": "Object", + "valueDescription": "New transform/crop info of the scene item" + } + ] + }, + { + "description": "A new scene has been created.", + "eventType": "SceneCreated", + "eventSubscription": "Scenes", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the new scene" + }, + { + "valueName": "isGroup", + "valueType": "Boolean", + "valueDescription": "Whether the new scene is a group" + } + ] + }, + { + "description": "A scene has been removed.", + "eventType": "SceneRemoved", + "eventSubscription": "Scenes", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the removed scene" + }, + { + "valueName": "isGroup", + "valueType": "Boolean", + "valueDescription": "Whether the scene was a group" + } + ] + }, + { + "description": "The name of a scene has changed.", + "eventType": "SceneNameChanged", + "eventSubscription": "Scenes", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "oldSceneName", + "valueType": "String", + "valueDescription": "Old name of the scene" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "New name of the scene" + } + ] + }, + { + "description": "The current program scene has changed.", + "eventType": "CurrentProgramSceneChanged", + "eventSubscription": "Scenes", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene that was switched to" + } + ] + }, + { + "description": "The current preview scene has changed.", + "eventType": "CurrentPreviewSceneChanged", + "eventSubscription": "Scenes", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene that was switched to" + } + ] + }, + { + "description": "The list of scenes has changed.\n\nTODO: Make OBS fire this event when scenes are reordered.", + "eventType": "SceneListChanged", + "eventSubscription": "Scenes", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "scenes", + "valueType": "Array", + "valueDescription": "Updated array of scenes" + } + ] + }, + { + "description": "The current scene transition has changed.", + "eventType": "CurrentSceneTransitionChanged", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the new transition" + } + ] + }, + { + "description": "The current scene transition duration has changed.", + "eventType": "CurrentSceneTransitionDurationChanged", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Transition duration in milliseconds" + } + ] + }, + { + "description": "A scene transition has started.", + "eventType": "SceneTransitionStarted", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Scene transition name" + } + ] + }, + { + "description": "A scene transition has completed fully.\n\nNote: Does not appear to trigger when the transition is interrupted by the user.", + "eventType": "SceneTransitionEnded", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Scene transition name" + } + ] + }, + { + "description": "A scene transition's video has completed fully.\n\nUseful for stinger transitions to tell when the video *actually* ends.\n`SceneTransitionEnded` only signifies the cut point, not the completion of transition playback.\n\nNote: Appears to be called by every transition, regardless of relevance.", + "eventType": "SceneTransitionVideoEnded", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Scene transition name" + } + ] + }, + { + "description": "Studio mode has been enabled or disabled.", + "eventType": "StudioModeStateChanged", + "eventSubscription": "Ui", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "dataFields": [ + { + "valueName": "studioModeEnabled", + "valueType": "Boolean", + "valueDescription": "True == Enabled, False == Disabled" + } + ] + }, + { + "description": "A screenshot has been saved.\n\nNote: Triggered for the screenshot feature available in `Settings -> Hotkeys -> Screenshot Output` ONLY.\nApplications using `Get/SaveSourceScreenshot` should implement a `CustomEvent` if this kind of inter-client\ncommunication is desired.", + "eventType": "ScreenshotSaved", + "eventSubscription": "Ui", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.1.0", + "category": "ui", + "dataFields": [ + { + "valueName": "savedScreenshotPath", + "valueType": "String", + "valueDescription": "Path of the saved image file" + } + ] + }, + { + "description": "An event has been emitted from a vendor.\n\nA vendor is a unique name registered by a third-party plugin or script, which allows for custom requests and events to be added to obs-websocket.\nIf a plugin or script implements vendor requests or events, documentation is expected to be provided with them.", + "eventType": "VendorEvent", + "eventSubscription": "Vendors", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "dataFields": [ + { + "valueName": "vendorName", + "valueType": "String", + "valueDescription": "Name of the vendor emitting the event" + }, + { + "valueName": "eventType", + "valueType": "String", + "valueDescription": "Vendor-provided event typedef" + }, + { + "valueName": "eventData", + "valueType": "Object", + "valueDescription": "Vendor-provided event data. {} if event does not provide any data" + } + ] + }, + { + "description": "Custom event emitted by `BroadcastCustomEvent`.", + "eventType": "CustomEvent", + "eventSubscription": "General", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "dataFields": [ + { + "valueName": "eventData", + "valueType": "Object", + "valueDescription": "Custom event data" + } + ] + } + ] +} diff --git a/addons/no-obs-ws/plugin.cfg b/addons/no-obs-ws/plugin.cfg new file mode 100644 index 0000000..ca61de6 --- /dev/null +++ b/addons/no-obs-ws/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="no-obs-ws" +description="An obs-websocket client and translation layer for GDScript" +author="Yagich" +version="0.1" +script="plugin.gd" diff --git a/addons/no-obs-ws/plugin.gd b/addons/no-obs-ws/plugin.gd new file mode 100644 index 0000000..aa1b8e0 --- /dev/null +++ b/addons/no-obs-ws/plugin.gd @@ -0,0 +1,12 @@ +@tool +extends EditorPlugin + + +func _enter_tree() -> void: + # Initialization of the plugin goes here. + pass + + +func _exit_tree() -> void: + # Clean-up of the plugin goes here. + pass diff --git a/addons/no_twitch/chat_socket.gd b/addons/no_twitch/chat_socket.gd new file mode 100644 index 0000000..eb7f1dc --- /dev/null +++ b/addons/no_twitch/chat_socket.gd @@ -0,0 +1,166 @@ +extends "res://addons/no_twitch/websocket_client.gd" + +## Wrapper class around [Websocket_Client] that handles Twitch Chat + +signal chat_received +signal chat_received_raw +signal chat_received_rich + +signal chat_connected + +var channels : Array[String] +var extra_info : bool + +var user_regex := RegEx.new() +var user_pattern := r":([\w]+)!" + + +func _init(): + + packet_received.connect(data_received) + + user_regex.compile(user_pattern) + + +## Connects to the Twitch IRC server. +func connect_to_chat(token, extra = false, nick = "terribletwitch"): + + extra_info = extra + + connect_to_url("wss://irc-ws.chat.twitch.tv:443") + await socket_open + send_text("PASS oauth:" + token) + send_text("NICK " + nick) + + if extra: + + send_text("CAP REQ :twitch.tv/commands twitch.tv/tags") + + + +## Handles checking the received packet from [signal Websocket_Client.packet_received] +func data_received(packet : PackedByteArray): + +# Gets the text from the packet, strips the end and splits the different lines + var messages = packet.get_string_from_utf8().strip_edges(false).split("\r\n") + + for msg in messages: + # Checks if this is a message that has tags enabled and if so, parses out the tags. + var tags : Dictionary + if msg.begins_with("@"): + + # Grabs the actual end of the string with the message in it. + var real_msg = msg.split(" ", false, 1) + msg = real_msg[1] + + # Loops through all the tags splitting them up by ; and then by = to get the keys and values. + for tag in real_msg[0].split(";"): + + var key_value = tag.split("=") + tags[key_value[0]] = key_value[1] + + + + parse_chat_msg(msg, tags) + + +#@badge-info=subscriber/34;badges=broadcaster/1,subscriber/6,game-developer/1;client-nonce=b5009ae3ee034a7706d86fe221882925;color=#2E8B57;display-name=EroAxee;emotes=;first-msg=0;flags=;id=be05dae8-4067-4edf-83f2-e6be02974904;mod=0;returning-chatter=0;room-id=160349129;subscriber=1;tmi-sent-ts=1702009303249;turbo=0;user-id=160349129;user-type= :eroaxee!eroaxee@eroaxee.tmi.twitch.tv PRIVMSG #eroaxee :More + +## Parses the given [param msg] [String] +func parse_chat_msg(msg : String, tags : Dictionary): + + var msg_dict : Dictionary + + if msg == "PING :tmi.twitch.tv": + + send_text(msg.replace("PING", "PONG")) + + return + + + var msg_notice = msg.split(" ")[1] + match msg_notice: + + "PRIVMSG": + + var space_split = msg.split(" ", true, 3) + + msg_dict["username"] = user_regex.search(msg).get_string(1) + msg_dict["message"] = space_split[3].trim_prefix(":") + msg_dict["channel"] = space_split[2].trim_prefix("#") + msg_dict.merge(parse_tags(tags)) + prints(msg_dict.username, msg_dict.message, msg_dict.channel) + +#(__username_regex.search(split[0]).get_string(1), split[3].right(1), split[2], tags) + + if !tags.is_empty(): + + chat_received_rich.emit(msg_dict) + return + + chat_received.emit(msg_dict) + + + # Connection Message + "001": + + prints("Connection Established", msg) + chat_connected.emit() + + # Chat Joining Message + "JOIN": + + pass + + # Chat Leaving Message + "PART": + + pass + + + +#@badge-info=subscriber/34;badges=broadcaster/1,subscriber/6,game-developer/1;client-nonce=02d73777ab1fab1aee33ada1830d52b5;color=#2E8B57;display-name=EroAxee;emotes=;first-msg=0;flags=;id=4ff91a8c-b965-43f8-85a1-ddd541a2b438;mod=0;returning-chatter=0;room-id=160349129;subscriber=1;tmi-sent-ts=1701850826667;turbo=0;user-id=160349129;user-type= :eroaxee!eroaxee@eroaxee.tmi.twitch.tv PRIVMSG #eroaxee :Stuff + +## Utility function that takes a Dictionary of tags from a Twitch message and parses them to be slightly more usable. +func parse_tags(tags : Dictionary): + + var new_tags : Dictionary + for all in tags.keys(): + + if all == "badges": + + tags[all] = tags[all].split(",") + + + new_tags[all.replace("-", "_")] = tags[all] + + + return new_tags +#{ "@badge-info": "subscriber/34", "badges": "broadcaster/1,subscriber/6,game-developer/1", "client-nonce": "b2e3524806f51c94cadd61d338bc14ed", "color": "#2E8B57", "display-name": "EroAxee", "emotes": "", "first-msg": "0", "flags": "", "id": "494dc47e-0d9c-4407-83ec-309764e1adf3", "mod": "0", "returning-chatter": "0", "room-id": "160349129", "subscriber": "1", "tmi-sent-ts": "1701853794297", "turbo": "0", "user-id": "160349129", "user-type": "" } + +## Wrapper function around [method WebSocketPeer.send_text] +func send_chat(msg : String, channel : String = ""): + + if channel.is_empty(): + + channel = channels[0] + + + channel = channel.strip_edges() + + send_text("PRIVMSG #" + channel + " :" + msg + "\r\n") + + +## Utility function that handles joining the supplied [param channel]'s chat. +func join_chat(channel : String): + + send_text("JOIN #" + channel + "\r\n") + channels.append(channel) + + +## Utility function that handles leaving the supplied [param channel]'s chat. +func leave_chat(channel : String): + + send_chat("PART #" + channel) + channels.erase(channel) + diff --git a/addons/no_twitch/demo/Chat_Join.gd b/addons/no_twitch/demo/Chat_Join.gd new file mode 100644 index 0000000..6bc2608 --- /dev/null +++ b/addons/no_twitch/demo/Chat_Join.gd @@ -0,0 +1,33 @@ +extends HBoxContainer + +var channel : String + +func _ready(): + + %Channel_Input.text_changed.connect(update_channel) + %Join_Chat.pressed.connect(join_chat) + %Start_Chat_Connection.pressed.connect(start_chat_connection) + + %Chat_Msg.text_submitted.connect(send_chat) + %Send_Button.pressed.connect(func(): send_chat(%Chat_Msg.text)) + + +func update_channel(new_channel): + + channel = new_channel + + +func start_chat_connection(): + + %Twitch_Connection.setup_chat_connection(channel) + + +func join_chat(): + + %Twitch_Connection.join_channel(channel) + + +func send_chat(chat : String): + + %Twitch_Connection.send_chat_to_channel(chat, %Channel.text) + diff --git a/addons/no_twitch/demo/demo_scene.gd b/addons/no_twitch/demo/demo_scene.gd new file mode 100644 index 0000000..1af75dd --- /dev/null +++ b/addons/no_twitch/demo/demo_scene.gd @@ -0,0 +1,27 @@ +extends Control + +func _ready(): + + $Twitch_Connection.token_received.connect(save_token) + + load_token() + + +func save_token(token): + + var res = TokenSaver.new() + res.token = token + ResourceSaver.save(res, "user://token.tres") + + +func load_token(): + + if !FileAccess.file_exists("user://token.tres"): + + return + + + var res = ResourceLoader.load("user://token.tres") + $Twitch_Connection.token = res.token + + diff --git a/addons/no_twitch/demo/demo_scene.tscn b/addons/no_twitch/demo/demo_scene.tscn new file mode 100644 index 0000000..a636685 --- /dev/null +++ b/addons/no_twitch/demo/demo_scene.tscn @@ -0,0 +1,81 @@ +[gd_scene load_steps=5 format=3 uid="uid://dhss3lpo1mhke"] + +[ext_resource type="Script" path="res://addons/no_twitch/twitch_connection.gd" id="1_13a4v"] +[ext_resource type="Script" path="res://addons/no_twitch/demo/demo_scene.gd" id="1_ebv0f"] +[ext_resource type="Script" path="res://addons/no_twitch/demo/test_button.gd" id="1_hhhwv"] +[ext_resource type="Script" path="res://addons/no_twitch/demo/Chat_Join.gd" id="2_b8f4l"] + +[node name="Control" type="Control"] +layout_mode = 3 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_ebv0f") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -206.0 +offset_top = -68.0 +offset_right = 211.0 +offset_bottom = 68.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Authenticate" type="Button" parent="VBoxContainer"] +layout_mode = 2 +text = "Authenticate" +script = ExtResource("1_hhhwv") + +[node name="Start_Chat_Connection" type="Button" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Start Connection" + +[node name="Chat_Join" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 +script = ExtResource("2_b8f4l") + +[node name="Channel_Input" type="LineEdit" parent="VBoxContainer/Chat_Join"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Channel" + +[node name="Join_Chat" type="Button" parent="VBoxContainer/Chat_Join"] +unique_name_in_owner = true +layout_mode = 2 +text = "Join" + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="Channel" type="LineEdit" parent="VBoxContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_stretch_ratio = 0.45 +placeholder_text = "Channel" + +[node name="Chat_Msg" type="LineEdit" parent="VBoxContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Chat Message" + +[node name="Send_Button" type="Button" parent="VBoxContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Send" + +[node name="Twitch_Connection" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("1_13a4v") diff --git a/addons/no_twitch/demo/test_button.gd b/addons/no_twitch/demo/test_button.gd new file mode 100644 index 0000000..7592510 --- /dev/null +++ b/addons/no_twitch/demo/test_button.gd @@ -0,0 +1,9 @@ +extends Button + + +@onready var twitch_connection : Twitch_Connection = $"../../Twitch_Connection" + +func _pressed(): + + twitch_connection.authenticate_with_twitch() + diff --git a/addons/no_twitch/demo/token_saver.gd b/addons/no_twitch/demo/token_saver.gd new file mode 100644 index 0000000..b7e1d43 --- /dev/null +++ b/addons/no_twitch/demo/token_saver.gd @@ -0,0 +1,4 @@ +extends Resource +class_name TokenSaver + +@export var token : String diff --git a/addons/no_twitch/plugin.cfg b/addons/no_twitch/plugin.cfg new file mode 100644 index 0000000..ad3e4e2 --- /dev/null +++ b/addons/no_twitch/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="No Twitch" +description="" +author="Eroax" +version="0.0.1" +script="plugin.gd" diff --git a/addons/no_twitch/plugin.gd b/addons/no_twitch/plugin.gd new file mode 100644 index 0000000..45a417e --- /dev/null +++ b/addons/no_twitch/plugin.gd @@ -0,0 +1,12 @@ +@tool +extends EditorPlugin + + +func _enter_tree(): + # Initialization of the plugin goes here. + pass + + +func _exit_tree(): + # Clean-up of the plugin goes here. + pass diff --git a/addons/no_twitch/twitch_connection.gd b/addons/no_twitch/twitch_connection.gd new file mode 100644 index 0000000..2c4abab --- /dev/null +++ b/addons/no_twitch/twitch_connection.gd @@ -0,0 +1,140 @@ +extends Node +class_name Twitch_Connection + +## Handler for a Connection to a Single Twitch Account +## +## Used for getting authentication and handling websocket connections. + +signal token_received(token : String) + +@export var client_id := "qyjg1mtby1ycs5scm1pvctos7yvyc1" +@export var redirect_uri := "http://localhost" +## Port that the redirect_uri will head to on your local system. Defaults to 80 for most cases. Linux tends to prefer 8000 or possibly 1338 +@export var redirect_port := "8000" + +var twitch_url := "https://id.twitch.tv/oauth2/authorize?response_type=token&" + +var auth_server : TCPServer +## Websocket used for handling the chat connection. +var chat_socket = preload("res://addons/no_twitch/chat_socket.gd").new() + +var state := make_state() +var token : String + +func _ready(): + + chat_socket.chat_received.connect(check_chat_socket) + + +## Handles the basic Twitch Authentication process to request and then later receive a Token (using [method check_auth_peer]) +func authenticate_with_twitch(client_id = client_id): + auth_server = TCPServer.new() + + OS.shell_open(create_auth_url()) + auth_server.listen(int(redirect_port)) + + +## Sets up a single chat connection. Joining 1 room with [param token] as it's "PASS" specifying what account it's on. And the optional [param default_chat] specifying the default room to join. While [param nick] specifies the "nickname" (Not the username on Twitch) +func setup_chat_connection(default_chat : String = "", token : String = token, request_twitch_info = true, nick = "terribletwitch"): + + # Temporary, closes the socket to allow connecting multiple times without needing to reset. + chat_socket.close() + # Connects to the Twitch IRC server. + chat_socket.connect_to_chat(token, request_twitch_info) + await chat_socket.socket_open + + if !default_chat.is_empty(): + + chat_socket.join_chat(default_chat) + + + +## Joins the given [param channel] over IRC. Essentially just sending JOIN #[param channel] +func join_channel(channel : String): + + chat_socket.join_chat(channel) + + +func send_chat(msg : String, channel : String = ""): + + chat_socket.send_chat(msg, channel) + + +func _process(delta): + + if auth_server and auth_server.is_listening() and auth_server.is_connection_available(): + + check_auth_peer(auth_server.take_connection()) + + + if chat_socket: + + chat_socket.poll_socket() + + + +## Utility function for creating a Twitch Authentication URL with the given +## [param scopes], Twitch Client ID ([param id]) and [param redirect_uri]. +## [param id] defaults to [member client_id] and both [param redirect] and +## [param redirect_port] +func create_auth_url(scopes : Array[String] = ["chat:read", "chat:edit"], port := redirect_port, id : String = client_id, redirect : String = redirect_uri): + + var str_scopes : String + + for all in scopes: + + str_scopes += " " + all + str_scopes = str_scopes.strip_edges() + + var full_redirect_uri := redirect + if !port.is_empty(): + full_redirect_uri += ":" + port + + var url = twitch_url + "client_id=" + id + "&redirect_uri=" + full_redirect_uri + "&scope=" + str_scopes + "&state=" + str(state) + + return url + +## Utility function for creating a "state" used for different requests for some extra security as a semi password. +func make_state(len : int = 16) -> String: + + var crypto = Crypto.new() + var state = crypto.generate_random_bytes(len).hex_encode() + + return state + + +func check_auth_peer(peer : StreamPeerTCP): + + var info = peer.get_utf8_string(peer.get_available_bytes()) + printraw(info) + + var root := redirect_uri + if !redirect_port.is_empty(): + root += ":" + redirect_port + root += "/" + + var script = "" + + peer.put_data(str("HTTP/1.1 200\n\n" + script).to_utf8_buffer()) + + + var resp_state = info.split("&state=") + +# Ensures that the received state is correct. + if !resp_state.size() > 1 or resp_state[0] == state: + + return + + + var token = info.split("access_token=")[1].split("&scope=")[0].strip_edges() + printraw("Token: ", token, "\n") + self.token = token + token_received.emit(token) + + +func check_chat_socket(dict): + + prints(dict.user, dict.message) + + + diff --git a/addons/no_twitch/websocket_client.gd b/addons/no_twitch/websocket_client.gd new file mode 100644 index 0000000..cfe4752 --- /dev/null +++ b/addons/no_twitch/websocket_client.gd @@ -0,0 +1,58 @@ +extends WebSocketPeer +class_name Websocket_Client + +## Helper Class for handling the freaking polling of a WebSocketPeer + +## Emitted when [method WebSocketPeer.get_ready_state()] returns +## [member WebSocketPeer.STATE_OPEN] +signal socket_open +## Emitted when [method WebSocketPeer.get_ready_state()] returns +## [member WebSocketPeer.STATE_CLOSED] +signal socket_closed(close_code, close_reason) +## Emitted when [method WebSocketPeer.get_ready_state()] returns +## [member WebSocketPeer.STATE_CONNECTING] +signal socket_connecting +## Emitted when [method WebSocketPeer.get_ready_state()] returns +## [member WebSocketPeer.STATE_CLOSING] +signal socket_closing +## Emitted when [method WebSocketPeer.get_available_packets()] returns greater +## than 0. Or, when a packet has been received. +signal packet_received(packet_data) + +## Works as a wrapper around [method WebSocketPeer.poll] to handle the logic of +## checking get_ready_state() more simply. +func poll_socket(): + + poll() + + var state = get_ready_state() + + match state: + + STATE_OPEN: + + socket_open.emit(get_connected_host(), get_connected_port()) + + if get_available_packet_count() > 0: + + packet_received.emit(get_packet()) + + + + STATE_CONNECTING: + + socket_connecting.emit() + + + STATE_CLOSING: + + socket_closing.emit() + + + STATE_CLOSED: + + socket_closed.emit(get_close_code(), get_close_reason()) + + + + diff --git a/classes/connections/connections.gd b/classes/connections/connections.gd new file mode 100644 index 0000000..ddf7b18 --- /dev/null +++ b/classes/connections/connections.gd @@ -0,0 +1,12 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +class_name Connections + +static var obs_websocket +static var twitch + +static func _twitch_chat_received(msg_dict : Dictionary): + + DeckHolder.send_event(&"twitch_chat", msg_dict) + diff --git a/classes/deck/deck.gd b/classes/deck/deck.gd index 2ca6ddc..7d0e320 100644 --- a/classes/deck/deck.gd +++ b/classes/deck/deck.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) class_name Deck ## A deck/graph with nodes. ## @@ -16,11 +19,13 @@ var save_path: String = "" var is_group: bool = false ## List of groups belonging to this deck, in the format of[br] ## [code]Dictionary[String -> Deck.id, Deck][/code] -var groups: Dictionary = {} -## A unique identifier for this deck. +#var groups: Dictionary = {} +## A unique identifier for this deck, or an ID for the group this deck represents. var id: String = "" +## If this is a group, this is the local ID of this instance of the group. +var instance_id: String = "" ## The parent deck of this deck, if this is a group. -var _belonging_to: Deck # for groups +#var _belonging_to: Deck # for groups ## The ID of this group's input node. Used only if [member is_group] is [code]true[/code]. var group_input_node: String ## The ID of this group's input node. Used only if [member is_group] is [code]true[/code]. @@ -28,13 +33,25 @@ var group_output_node: String ## The ID of the group node this group is represented by, contained in this deck's parent deck. ## Used only if [member is_group] is [code]true[/code]. ## @experimental -var group_node: String +#var group_node: String + +var emit_group_signals: bool = true +var emit_node_added_signal: bool = true ## Emitted when a node has been added to this deck. signal node_added(node: DeckNode) ## Emitted when a node has been removed from this deck. signal node_removed(node: DeckNode) +#region group signals +signal node_added_to_group(node: DeckNode, assign_id: String, assign_to_self: bool, deck: Deck) +signal node_removed_from_group(node_id: String, remove_connections: bool, deck: Deck) +signal nodes_connected_in_group(from_node_id: String, to_node_id: String, from_output_port: int, to_input_port: int, deck: Deck) +signal nodes_disconnected_in_group(from_node_id: String, to_node_id: String, from_output_port: int, to_input_port: int, deck: Deck) +signal node_port_value_updated(node_id: String, port_idx: int, new_value: Variant, deck: Deck) +signal node_renamed(node_id: String, new_name: String, deck: Deck) +signal node_moved(node_id: String, new_position: Dictionary, deck: Deck) +#endregion ## Instantiate a node by its' [member DeckNode.node_type] and add it to this deck.[br] ## See [method add_node_inst] for parameter descriptions. @@ -59,8 +76,31 @@ func add_node_inst(node: DeckNode, assign_id: String = "", assign_to_self: bool node._id = uuid else: nodes[assign_id] = node + node._id = assign_id - node_added.emit(node) + if emit_node_added_signal: + node_added.emit(node) + + if is_group && emit_group_signals: + node_added_to_group.emit(node, node._id, assign_to_self, self) + + node.port_value_updated.connect( + func(port_idx: int, new_value: Variant): + if is_group && emit_group_signals: + node_port_value_updated.emit(node._id, port_idx, new_value, self) + ) + + node.renamed.connect( + func(new_name: String): + if is_group && emit_group_signals: + node_renamed.emit(node._id, new_name, self) + ) + + node.position_updated.connect( + func(new_position: Dictionary): + if is_group && emit_group_signals: + node_moved.emit(node._id, new_position, self) + ) return node @@ -71,26 +111,36 @@ func get_node(uuid: String) -> DeckNode: ## Attempt to connect two nodes. Returns [code]true[/code] if the connection succeeded. -func connect_nodes(from_node: DeckNode, to_node: DeckNode, from_output_port: int, to_input_port: int) -> bool: +func connect_nodes(from_node_id: String, to_node_id: String, from_output_port: int, to_input_port: int) -> bool: + var from_node := get_node(from_node_id) + var to_node := get_node(to_node_id) # check that we can do the type conversion var type_a: DeckType.Types = from_node.get_output_ports()[from_output_port].type var type_b: DeckType.Types = to_node.get_input_ports()[to_input_port].type if !DeckType.can_convert(type_a, type_b): - print("Can not convert from %s to %s." % [DeckType.type_str(type_a), DeckType.type_str(type_b)]) + DeckHolder.logger.log_deck( + "Can not convert from %s to %s." % [DeckType.type_str(type_a), DeckType.type_str(type_b)], + Logger.LogType.ERROR + ) return false # TODO: prevent duplicate connections + if is_group && emit_group_signals: + nodes_connected_in_group.emit(from_node_id, to_node_id, from_output_port, to_input_port, self) + from_node.add_outgoing_connection(from_output_port, to_node._id, to_input_port) return true ## Remove a connection from two nodes. -func disconnect_nodes(from_node: DeckNode, to_node: DeckNode, from_output_port: int, to_input_port: int) -> void: - var hash = {to_node._id: to_input_port}.hash() - - from_node.remove_outgoing_connection(from_output_port, hash) +func disconnect_nodes(from_node_id: String, to_node_id: String, from_output_port: int, to_input_port: int) -> void: + var from_node := get_node(from_node_id) + var to_node := get_node(to_node_id) + from_node.remove_outgoing_connection(from_output_port, to_node_id, to_input_port) to_node.remove_incoming_connection(to_input_port) + if is_group && emit_group_signals: + nodes_disconnected_in_group.emit(from_node_id, to_node_id, from_output_port, to_input_port, self) ## Returns true if this deck has no nodes and no variables. @@ -99,12 +149,38 @@ func is_empty() -> bool: ## Remove a node from this deck. -func remove_node(uuid: String) -> void: - var node = nodes.get(uuid) +func remove_node(uuid: String, remove_connections: bool = false, force: bool = false, keep_group_instances: bool = false) -> void: + var node := get_node(uuid) + if node == null: + return + + if !node.user_can_delete && !force: + return + + if node.node_type == "group_node" && !keep_group_instances: + DeckHolder.close_group_instance(node.group_id, node.group_instance_id) + + if remove_connections: + var outgoing_connections := node.outgoing_connections.duplicate(true) + + for output_port: int in outgoing_connections: + for to_node: String in outgoing_connections[output_port]: + for to_port: int in outgoing_connections[output_port][to_node]: + disconnect_nodes(uuid, to_node, output_port, to_port) + + var incoming_connections := node.incoming_connections.duplicate(true) + + for input_port: int in incoming_connections: + for from_node: String in incoming_connections[input_port]: + disconnect_nodes(from_node, uuid, incoming_connections[input_port][from_node], input_port) + nodes.erase(uuid) node_removed.emit(node) + if is_group && emit_group_signals: + node_removed_from_group.emit(uuid, remove_connections, self) + ## Group the [param nodes_to_group] into a new deck and return it. ## Returns [code]null[/code] on failure.[br] @@ -112,71 +188,217 @@ func remove_node(uuid: String) -> void: func group_nodes(nodes_to_group: Array) -> Deck: if nodes_to_group.is_empty(): return null - + + # don't include nodes that can't be grouped/deleted + nodes_to_group = nodes_to_group.filter( + func(x: DeckNode): + return x.user_can_delete + ) + var node_ids_to_keep := nodes_to_group.map( func(x: DeckNode): return x._id ) - var group := Deck.new() - group.is_group = true - group._belonging_to = self - var group_id := UUID.v4() - group.id = group_id + var group := DeckHolder.add_empty_group() var midpoint := Vector2() + + var rightmost := -INF + var leftmost := INF for node: DeckNode in nodes_to_group: - if node.node_type == "group_node": # for recursive grouping - var _group_id: String = node.group_id - var _group: Deck = groups[_group_id] - groups.erase(_group) - group.groups[_group_id] = _group - _group._belonging_to = group + #if node.node_type == "group_node": + #var _group_id: String = node.group_id + #var _group: Deck = groups[_group_id] + #groups.erase(_group) + #group.groups[_group_id] = _group + #_group._belonging_to = group + + if node.position.x > rightmost: + rightmost = node.position.x + if node.position.x < leftmost: + leftmost = node.position.x - for from_port: int in node.outgoing_connections: - for connection: Dictionary in node.outgoing_connections[from_port]: - if !(connection.keys()[0] in node_ids_to_keep): - disconnect_nodes(node, get_node(connection.keys()[0]), from_port, connection.values()[0]) + var outgoing_connections := node.outgoing_connections.duplicate(true) - for to_port: int in node.incoming_connections: - for from_node: String in node.incoming_connections[to_port]: + for from_port: int in outgoing_connections: + for to_node: String in outgoing_connections[from_port]: + for to_port: int in outgoing_connections[from_port][to_node]: + if !(to_node in node_ids_to_keep): + disconnect_nodes(node._id, to_node, from_port, to_port) + + var incoming_connections := node.incoming_connections.duplicate(true) + + for to_port: int in incoming_connections: + for from_node: String in incoming_connections[to_port]: if !(from_node in node_ids_to_keep): - disconnect_nodes(get_node(from_node), node, node.incoming_connections[to_port].values()[0], to_port) + disconnect_nodes(from_node, node._id, incoming_connections[to_port][from_node], to_port) midpoint += node.position_as_vector2() - remove_node(node._id) + remove_node(node._id, false, true) group.add_node_inst(node, node._id) midpoint /= nodes_to_group.size() + emit_node_added_signal = false var _group_node := add_node_type("group_node") - _group_node.group_id = group_id + _group_node.group_id = group.id + _group_node.group_instance_id = group.instance_id _group_node.position.x = midpoint.x _group_node.position.y = midpoint.y _group_node.position_updated.emit(_group_node.position) - group.group_node = _group_node._id - + #group.group_node = _group_node._id + node_added.emit(_group_node) + emit_node_added_signal = true + var input_node := group.add_node_type("group_input") var output_node := group.add_node_type("group_output") group.group_input_node = input_node._id group.group_output_node = output_node._id + + input_node.position.x = leftmost - 350 + output_node.position.x = rightmost + 350 + input_node.position.y = midpoint.y + output_node.position.y = midpoint.y + input_node.position_updated.emit(input_node.position) + output_node.position_updated.emit(output_node.position) - _group_node.input_node = input_node - _group_node.output_node = output_node - _group_node.setup_connections() + input_node.group_node = _group_node + output_node.group_node = _group_node - groups[group_id] = group + _group_node.input_node_id = input_node._id + _group_node.output_node_id = output_node._id + #_group_node.setup_connections() + _group_node.init_io() return group ## Get a group belonging to this deck by its ID. -func get_group(uuid: String) -> Deck: - return groups.get(uuid) +#func get_group(uuid: String) -> Deck: + #return groups.get(uuid) + + +func copy_nodes(nodes_to_copy: Array[String]) -> Dictionary: + var d := {"nodes": {}} + + for node_id: String in nodes_to_copy: + d.nodes[node_id] = get_node(node_id).to_dict() + + for node: String in d.nodes: + var outgoing_connections: Dictionary = d.nodes[node].outgoing_connections.duplicate(true) + + for from_port: int in outgoing_connections: + for to_node: String in outgoing_connections[from_port]: + if !(to_node in nodes_to_copy): + (d.nodes[node].outgoing_connections[from_port] as Dictionary).erase(to_node) + + var incoming_connections: Dictionary = d.nodes[node].incoming_connections.duplicate(true) + + for to_port: int in incoming_connections: + for from_node: String in incoming_connections[to_port]: + if !(from_node in nodes_to_copy): + (d.nodes[node].incoming_connections[to_port] as Dictionary).erase(from_node) + + for node: Dictionary in d.nodes.values().slice(1): + node.position.x = node.position.x - d.nodes.values()[0].position.x + node.position.y = node.position.y - d.nodes.values()[0].position.y + + d.nodes.values()[0].position.x = 0 + d.nodes.values()[0].position.y = 0 + + return d + + +func copy_nodes_json(nodes_to_copy: Array[String]) -> String: + return JSON.stringify(copy_nodes(nodes_to_copy)) + + +func allocate_ids(count: int) -> Array[String]: + var res: Array[String] = [] + for i in count: + res.append(UUID.v4()) + return res + + +func paste_nodes_from_dict(nodes: Dictionary, position: Vector2 = Vector2()) -> void: + if !nodes.get("nodes"): + return + + var new_ids := allocate_ids(nodes.nodes.size()) + var ids_map := {} + for i: int in nodes.nodes.keys().size(): + var node_id: String = nodes.nodes.keys()[i] + ids_map[node_id] = new_ids[i] + + for node_id: String in nodes.nodes: + nodes.nodes[node_id]._id = ids_map[node_id] + + nodes.nodes[node_id].position.x += position.x + nodes.nodes[node_id].position.y += position.y + + var outgoing_connections: Dictionary = nodes.nodes[node_id].outgoing_connections as Dictionary + var outgoing_connections_res := {} + for from_port in outgoing_connections: + outgoing_connections_res[from_port] = {} + for to_node_id in outgoing_connections[from_port]: + outgoing_connections_res[from_port][ids_map[to_node_id]] = outgoing_connections[from_port][to_node_id] + + var incoming_connections: Dictionary = nodes.nodes[node_id].incoming_connections as Dictionary + var incoming_connections_res := {} + for to_port in incoming_connections: + incoming_connections_res[to_port] = {} + for from_node_id in incoming_connections[to_port]: + incoming_connections_res[to_port][ids_map[from_node_id]] = incoming_connections[to_port][from_node_id] + + nodes.nodes[node_id].outgoing_connections = outgoing_connections_res + nodes.nodes[node_id].incoming_connections = incoming_connections_res + + var node := DeckNode.from_dict(nodes.nodes[node_id]) + if node.node_type == "group_node": + var group := DeckHolder.make_new_group_instance(node.group_id) + node.group_instance_id = group.instance_id + group.get_node(group.group_input_node).group_node = node + group.get_node(group.group_output_node).group_node = node + node.input_node = group.get_node(group.group_input_node) + node.output_node = group.get_node(group.group_output_node) + node.init_io() + add_node_inst(node, ids_map[node_id]) + + +func paste_nodes_from_json(json: String, position: Vector2 = Vector2()) -> void: + paste_nodes_from_dict(JSON.parse_string(json), position) + + +func duplicate_nodes(nodes: Array[String]) -> void: + if nodes.is_empty(): + return + + var position := get_node(nodes[0]).position_as_vector2() + Vector2(50, 50) + var d := copy_nodes(nodes) + paste_nodes_from_dict(d, position) + + +func send_event(event_name: StringName, event_data: Dictionary = {}) -> void: + for node: DeckNode in nodes.values(): + node._event_received(event_name, event_data) + + +func get_referenced_groups() -> Array[String]: + # this is expensive + # recursively returns a list of all groups referenced by this deck + var res: Array[String] = [] + for node_id: String in nodes: + var node := get_node(node_id) + if node.node_type != "group_node": + continue + res.append(node.group_id) + res.append_array(DeckHolder.get_deck(node.group_id).get_referenced_groups()) + return res ## Returns a [Dictionary] representation of this deck. -func to_dict(with_meta: bool = true) -> Dictionary: +func to_dict(with_meta: bool = true, group_ids: Array = []) -> Dictionary: var inner := { "nodes": {}, "variable_stack": variable_stack, @@ -184,14 +406,18 @@ func to_dict(with_meta: bool = true) -> Dictionary: "groups": {} } - for node_id in nodes.keys(): + for node_id: String in nodes.keys(): inner["nodes"][node_id] = nodes[node_id].to_dict(with_meta) + if (nodes[node_id] as DeckNode).node_type == "group_node": + if !(nodes[node_id].group_id in group_ids): + inner["groups"][nodes[node_id].group_id] = DeckHolder.get_deck(nodes[node_id].group_id).to_dict(with_meta, group_ids) + group_ids.append(nodes[node_id].group_id) - for group_id in groups.keys(): - inner["groups"][group_id] = groups[group_id].to_dict(with_meta) + #for group_id in groups.keys(): + #inner["groups"][group_id] = groups[group_id].to_dict(with_meta) if is_group: - inner["group_node"] = group_node + inner["instance_id"] = instance_id inner["group_input_node"] = group_input_node inner["group_output_node"] = group_output_node @@ -217,52 +443,32 @@ static func from_dict(data: Dictionary, path: String = "") -> Deck: var nodes_data: Dictionary = data.deck.nodes as Dictionary for node_id in nodes_data: - var node := deck.add_node_type(nodes_data[node_id].node_type, node_id, false) - node._id = node_id - node.name = nodes_data[node_id].name - node._belonging_to = deck - node.position = nodes_data[node_id].position - - for prop in nodes_data[node_id].props: - node.set(prop, nodes_data[node_id].props[prop]) - - node._pre_connection() - - for connection_id in nodes_data[node_id].outgoing_connections: - var connection_data = nodes_data[node_id].outgoing_connections[connection_id] - for connection in connection_data: - connection[connection.keys()[0]] = int(connection.values()[0]) - node.outgoing_connections[int(connection_id)] = connection_data - - for connection_id in nodes_data[node_id].incoming_connections: - var connection_data = nodes_data[node_id].incoming_connections[connection_id] - for connection in connection_data: - connection_data[connection] = int(connection_data[connection]) - node.incoming_connections[int(connection_id)] = connection_data - - for i in node.ports.size(): - var port_value: Variant - if (nodes_data[node_id].port_values as Array).size() <= i: - port_value = null - else: - port_value = (nodes_data[node_id].port_values as Array)[i] - node.ports[i].value = port_value - - for key in nodes_data[node_id].meta: - node.set_meta(key, str_to_var(nodes_data[node_id].meta[key])) - - node._post_load() + var node := DeckNode.from_dict(nodes_data[node_id]) + deck.add_node_inst(node, node_id) var groups_data: Dictionary = data.deck.groups as Dictionary - for group_id: String in groups_data: - var group := Deck.from_dict(groups_data[group_id]) - group._belonging_to = deck - group.is_group = true - deck.groups[group_id] = group - group.group_node = groups_data[group_id]["deck"]["group_node"] - group.group_input_node = groups_data[group_id]["deck"]["group_input_node"] - group.group_output_node = groups_data[group_id]["deck"]["group_output_node"] - deck.get_node(group.group_node).init_io() + for node_id: String in deck.nodes: + var node := deck.get_node(node_id) + if node.node_type != "group_node": + continue + var group_id: String = node.group_id + var group_instance_id: String = node.group_instance_id + var group_data: Dictionary = groups_data[group_id] + var group := DeckHolder.add_group_from_dict(group_data, group_id, group_instance_id) + group.get_node(group.group_input_node).group_node = node + group.get_node(group.group_output_node).group_node = node + node.init_io() + + #for group_id: String in groups_data: + #var group := Deck.from_dict(groups_data[group_id]) + #group._belonging_to = deck + #group.is_group = true + #deck.groups[group_id] = group + #group.group_node = groups_data[group_id]["deck"]["group_node"] + #group.group_input_node = groups_data[group_id]["deck"]["group_input_node"] + #group.group_output_node = groups_data[group_id]["deck"]["group_output_node"] + #deck.get_node(group.group_node).init_io() + return deck diff --git a/classes/deck/deck_holder.gd b/classes/deck/deck_holder.gd index 14879f9..1e2e2bc 100644 --- a/classes/deck/deck_holder.gd +++ b/classes/deck/deck_holder.gd @@ -1,16 +1,22 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) class_name DeckHolder ## @experimental ## A static class holding references to all decks opened in the current session. ## List of decks opened this session. -static var decks: Array[Deck] +#static var decks: Array[Deck] +static var decks: Dictionary # Dictionary[String -> id, (Deck|Dictionary[String -> instance_id, Deck])] + +static var logger := Logger.new() ## Returns a new empty deck and assigns a new random ID to it. static func add_empty_deck() -> Deck: var deck := Deck.new() - DeckHolder.decks.append(deck) var uuid := UUID.v4() + decks[uuid] = deck deck.id = uuid return deck @@ -21,12 +27,200 @@ static func open_deck_from_file(path: String) -> Deck: if f.get_error() != OK: return null - var deck := Deck.from_dict(JSON.parse_string(f.get_as_text()), path) - DeckHolder.decks.append(deck) - + var deck := open_deck_from_dict(JSON.parse_string(f.get_as_text()), path) return deck -## Unloads a deck. -static func close_deck(deck: Deck) -> void: - DeckHolder.decks.erase(deck) +static func open_deck_from_dict(data: Dictionary, path := "") -> Deck: + var deck := Deck.from_dict(data, path) + decks[deck.id] = deck + return deck + + +static func add_group_from_dict(data: Dictionary, deck_id: String, instance_id: String) -> Deck: + var group := Deck.from_dict(data) + group.instance_id = instance_id + group.is_group = true + group.group_input_node = data.deck.group_input_node + group.group_output_node = data.deck.group_output_node + var instances: Dictionary = decks.get(deck_id, {}) + instances[instance_id] = group + decks[deck_id] = instances + connect_group_signals(group) + return group + + +static func make_new_group_instance(group_id: String) -> Deck: + var group := get_deck(group_id) + var data := group.to_dict() + return add_group_from_dict(data, group_id, UUID.v4()) + + +static func add_empty_group() -> Deck: + var group := Deck.new() + group.is_group = true + group.id = UUID.v4() + group.instance_id = UUID.v4() + decks[group.id] = {group.instance_id: group} + connect_group_signals(group) + return group + + +static func connect_group_signals(group: Deck) -> void: + group.node_added_to_group.connect(DeckHolder._on_node_added_to_group) + group.node_removed_from_group.connect(DeckHolder._on_node_removed_from_group) + group.nodes_connected_in_group.connect(DeckHolder._on_nodes_connected_in_group) + group.nodes_disconnected_in_group.connect(DeckHolder._on_nodes_disconnected_in_group) + group.node_port_value_updated.connect(DeckHolder._on_node_port_value_updated) + group.node_renamed.connect(DeckHolder._on_node_renamed) + group.node_moved.connect(DeckHolder._on_node_moved) + + +static func get_deck(id: String) -> Deck: + if !decks.has(id): + return null + + if !(decks[id] is Dictionary): + return decks[id] + else: + return (decks[id] as Dictionary).values()[0] + + +static func get_group_instance(group_id: String, instance_id: String) -> Deck: + if !decks.has(group_id): + return null + + if decks[group_id] is Dictionary: + return (decks[group_id] as Dictionary).get(instance_id) + else: + return null + + +static func close_group_instance(group_id: String, instance_id: String) -> void: + # this is kinda dumb, but to close groups that may be dangling + # when all instances are closed, we have to get that list + # *before* we close the instance + var dangling_groups := get_deck(group_id).get_referenced_groups() + + var group_instances: Dictionary = decks.get(group_id, {}) as Dictionary + group_instances.erase(instance_id) + if group_instances.is_empty(): + for group in dangling_groups: + close_all_group_instances(group) + decks.erase(group_id) + + +static func close_all_group_instances(group_id: String) -> void: + if decks.get(group_id) is Dictionary: + decks.erase(group_id) + + +## Unloads a deck. Returns a list of groups that are closed as a result of +## closing this deck. +static func close_deck(deck_id: String) -> Array: + if decks.get(deck_id) is Deck: + var deck: Deck = decks[deck_id] as Deck + var groups := deck.get_referenced_groups() + for group in groups: + close_all_group_instances(group) + decks.erase(deck_id) + return groups + + return [] + + +static func send_event(event_name: StringName, event_data: Dictionary = {}) -> void: + for deck_id: String in decks: + if decks[deck_id] is Deck: + (decks[deck_id] as Deck).send_event(event_name, event_data) + else: + for deck_instance_id: String in decks[deck_id]: + (decks[deck_id][deck_instance_id] as Deck).send_event(event_name, event_data) + + +#region group signal callbacks +static func _on_node_added_to_group(node: DeckNode, assign_id: String, assign_to_self: bool, deck: Deck) -> void: + var group_id := deck.id + for instance_id: String in decks[group_id]: + if instance_id == deck.instance_id: + continue + + var instance: Deck = get_group_instance(group_id, instance_id) + instance.emit_group_signals = false + var node_duplicate := DeckNode.from_dict(node.to_dict()) + instance.add_node_inst(node_duplicate, assign_id, assign_to_self) + instance.emit_group_signals = true + + +static func _on_node_removed_from_group(node_id: String, remove_connections: bool, deck: Deck) -> void: + var group_id := deck.id + for instance_id: String in decks[group_id]: + if instance_id == deck.instance_id: + continue + + var instance: Deck = get_group_instance(group_id, instance_id) + instance.emit_group_signals = false + instance.remove_node(node_id, remove_connections) + instance.emit_group_signals = true + + +static func _on_nodes_connected_in_group(from_node_id: String, to_node_id: String, from_output_port: int, to_input_port: int, deck: Deck) -> void: + var group_id := deck.id + for instance_id: String in decks[group_id]: + if instance_id == deck.instance_id: + continue + + var instance: Deck = get_group_instance(group_id, instance_id) + instance.emit_group_signals = false + instance.connect_nodes(from_node_id, to_node_id, from_output_port, to_input_port) + instance.emit_group_signals = true + + +static func _on_nodes_disconnected_in_group(from_node_id: String, to_node_id: String, from_output_port: int, to_input_port: int, deck: Deck) -> void: + var group_id := deck.id + for instance_id: String in decks[group_id]: + if instance_id == deck.instance_id: + continue + + var instance: Deck = get_group_instance(group_id, instance_id) + instance.emit_group_signals = false + instance.disconnect_nodes(from_node_id, to_node_id, from_output_port, to_input_port) + instance.emit_group_signals = true + + +static func _on_node_port_value_updated(node_id: String, port_idx: int, new_value: Variant, deck: Deck) -> void: + var group_id := deck.id + for instance_id: String in decks[group_id]: + if instance_id == deck.instance_id: + continue + + var instance: Deck = get_group_instance(group_id, instance_id) + instance.emit_group_signals = false + instance.get_node(node_id).get_all_ports()[port_idx].set_value_no_signal(new_value) + instance.emit_group_signals = true + + +static func _on_node_renamed(node_id: String, new_name: String, deck: Deck) -> void: + var group_id := deck.id + for instance_id: String in decks[group_id]: + if instance_id == deck.instance_id: + continue + + var instance: Deck = get_group_instance(group_id, instance_id) + instance.emit_group_signals = false + instance.get_node(node_id).name = new_name + instance.emit_group_signals = true + + +static func _on_node_moved(node_id: String, new_position: Dictionary, deck: Deck) -> void: + var group_id := deck.id + for instance_id: String in decks[group_id]: + if instance_id == deck.instance_id: + continue + + var instance: Deck = get_group_instance(group_id, instance_id) + instance.emit_group_signals = false + instance.get_node(node_id).position = new_position.duplicate() + instance.emit_group_signals = true + +#endregion diff --git a/classes/deck/deck_node.gd b/classes/deck/deck_node.gd index 6c01732..3c39a07 100644 --- a/classes/deck/deck_node.gd +++ b/classes/deck/deck_node.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) class_name DeckNode ## A node in a [Deck]. ## @@ -8,7 +11,7 @@ class_name DeckNode var name: String ## A map of outgoing connections from this node, in the format[br] -## [code]Dictionary[int -> output port, Array[Dictionary[String -> DeckNode#_id, int -> input port]]][/code] +## [code]Dictionary[int -> output port, Dictionary[String -> DeckNode#_id, Array[int -> input port]]][/code] var outgoing_connections: Dictionary ## A map of incoming connections to this node, in the format[br] ## [code]Dictionary[int -> input port, [Dictionary[String -> DeckNode#_id, int -> output port]][/code] @@ -41,6 +44,10 @@ var props_to_serialize: Array[StringName] ## Only used by renderers. var position: Dictionary = {"x": 0.0, "y": 0.0} +## If [code]true[/code], the user can delete this node by normal means. +## The parent [Deck] can still delete the node by other means. +var user_can_delete: bool = true + enum PortType{ INPUT, ## Input port type (slot on the left). OUTPUT, ## Output port type (slot on the right). @@ -64,6 +71,10 @@ signal incoming_connection_added(from_port: int) ## Emitted when a connection to this node has been removed. signal incoming_connection_removed(from_port: int) +signal port_value_updated(port_idx: int, new_value: Variant) + +signal renamed(new_name: String) + ## Add an input port to this node. Usually only used at initialization. func add_input_port(type: DeckType.Types, label: String, descriptor: String = "") -> void: @@ -85,10 +96,15 @@ func add_port(type: DeckType.Types, label: String, port_type: PortType, index_of var port := Port.new(type, label, ports.size(), port_type, index_of_type, descriptor) ports.append(port) port_added.emit(ports.size() - 1) + port.value_updated.connect( + func(new_value: Variant) -> void: + port_value_updated.emit(port.index, new_value) + ) ports_updated.emit() ## Remove a port from this node. +## @deprecated func remove_port(port_idx: int) -> void: outgoing_connections.erase(port_idx) incoming_connections.erase(port_idx) @@ -101,15 +117,13 @@ func send(from_output_port: int, data: Variant, extra_data: Array = []) -> void: if outgoing_connections.get(from_output_port) == null: return - for connection in outgoing_connections[from_output_port]: - connection = connection as Dictionary - # key is node uuid - # value is input port on destination node - for node in connection: - get_node(node)._receive(connection[node], data, extra_data) + for node: String in outgoing_connections[from_output_port]: + for input_port: int in outgoing_connections[from_output_port][node]: + get_node(node)._receive(input_port, data, extra_data) ## Virtual function that's called when this node receives data from another node's [method send] call. +@warning_ignore("unused_parameter") func _receive(to_input_port: int, data: Variant, extra_data: Array = []) -> void: pass @@ -117,9 +131,11 @@ func _receive(to_input_port: int, data: Variant, extra_data: Array = []) -> void ## Add a connection from the output port at [param from_port] to [param to_node]'s input port ## at [param to_port]. func add_outgoing_connection(from_port: int, to_node: String, to_port: int) -> void: - var port_connections: Array = outgoing_connections.get(from_port, []) - port_connections.append({to_node: to_port}) - outgoing_connections[from_port] = port_connections + var inner: Dictionary = outgoing_connections.get(from_port, {}) as Dictionary + var inner_ports: Array = inner.get(to_node, []) as Array + inner_ports.append(to_port) + inner[to_node] = inner_ports + outgoing_connections[from_port] = inner get_node(to_node).add_incoming_connection(to_port, _id, from_port) outgoing_connection_added.emit(from_port) @@ -144,33 +160,38 @@ func request_value(on_port: int) -> Variant: return node._value_request(connection.values()[0]) +## Asynchronous version of [method request_value]. +func request_value_async(on_port: int) -> Variant: + if !incoming_connections.has(on_port): + return null + + var connection: Dictionary = incoming_connections[on_port] + var node := get_node(connection.keys()[0]) + return await node._value_request(connection.values()[0]) + + ## Virtual function that's called when this node has been requested a value from the output port ## at [param from_port]. +@warning_ignore("unused_parameter") func _value_request(from_port: int) -> Variant: return null ## Remove an outgoing connection from this node. ## Does [b]not[/b] remove the other node's incoming connection equivalent. -func remove_outgoing_connection(from_port: int, connection_hash: int) -> void: - var port_connections: Array = (outgoing_connections.get(from_port, []) as Array).duplicate(true) - if port_connections.is_empty(): +func remove_outgoing_connection(from_port: int, to_node: String, to_port: int) -> void: + var connections: Dictionary = outgoing_connections.get(from_port, {}) as Dictionary + if connections.is_empty(): return - var incoming_connection := {} + var inner_ports: Array = connections.get(to_node, []) as Array + inner_ports.erase(to_port) - var to_remove: int = -1 - for i in port_connections.size(): - if port_connections[i].hash() == connection_hash: + if inner_ports.is_empty(): + (outgoing_connections[from_port] as Dictionary).erase(to_node) + if (outgoing_connections[from_port] as Dictionary).is_empty(): + outgoing_connections.erase(from_port) - to_remove = i - - if to_remove == -1: - print('nothing to remove') - return - - port_connections.remove_at(to_remove) - outgoing_connections[from_port] = port_connections outgoing_connection_removed.emit(from_port) @@ -181,6 +202,16 @@ func remove_incoming_connection(to_port: int) -> void: incoming_connection_removed.emit(to_port) +func rename(new_name: String) -> void: + name = new_name + renamed.emit(new_name) + + +@warning_ignore("unused_parameter") +func _event_received(event_name: StringName, event_data: Dictionary = {}) -> void: + pass + + ## Returns a list of all input ports. func get_input_ports() -> Array[Port]: return ports.filter( @@ -254,17 +285,34 @@ func _post_load() -> void: pass +## A helper function to get a value on an input port. Returns the best match in the following +## order of priority:[br] +## 1. The direct result of [method request value]. [br] +## 2. The result of [method Port.value_callback]. [br] +## 3. The input [Port] at index [param input_port]'s stored [member Port.value]. [br] +## 4. [code]null[/code]. +func resolve_input_port_value(input_port: int) -> Variant: + if request_value(input_port) != null: + return request_value(input_port) + elif get_input_ports()[input_port].value_callback.get_object() && get_input_ports()[input_port].value_callback.call() != null: + return get_input_ports()[input_port].value_callback.call() + elif get_input_ports()[input_port].value != null: + return get_input_ports()[input_port].value + else: + return null + + ## Returns a [Dictionary] representation of this node. func to_dict(with_meta: bool = true) -> Dictionary: var d := { "_id": _id, "name": name, - "outgoing_connections": outgoing_connections, - "incoming_connections": incoming_connections, + "outgoing_connections": outgoing_connections.duplicate(true), + "incoming_connections": incoming_connections.duplicate(true), "props": {}, "node_type": node_type, "port_values": [], - "position": position, + "position": position.duplicate(), } for prop in props_to_serialize: @@ -282,6 +330,48 @@ func to_dict(with_meta: bool = true) -> Dictionary: return d +static func from_dict(data: Dictionary) -> DeckNode: + var node := NodeDB.instance_node(data.node_type) + #node._id = data._id + node.name = data.name + node.position = data.position + + for prop in data.props: + node.set(prop, data.props[prop]) + + node._pre_connection() + + for from_port in data.outgoing_connections: + var connection_data: Dictionary = data.outgoing_connections[from_port] + node.outgoing_connections[int(from_port)] = {} + for to_node in connection_data: + var input_ports: Array = connection_data[to_node] + node.outgoing_connections[int(from_port)][to_node] = [] + for to_input_port in input_ports: + node.outgoing_connections[int(from_port)][to_node].append(int(to_input_port)) + + for to_port in data.incoming_connections: + var connection_data = data.incoming_connections[to_port] + for connection in connection_data: + connection_data[connection] = int(connection_data[connection]) + node.incoming_connections[int(to_port)] = connection_data + + for i in node.ports.size(): + var port_value: Variant + if (data.port_values as Array).size() <= i: + port_value = null + else: + port_value = (data.port_values as Array)[i] + node.ports[i].value = port_value + + for key in data.meta: + node.set_meta(key, str_to_var(data.meta[key])) + + node._post_load() + + return node + + ## Returns the node's [member position] as a [Vector2]. func position_as_vector2() -> Vector2: return Vector2(position.x, position.y) diff --git a/classes/deck/logger.gd b/classes/deck/logger.gd new file mode 100644 index 0000000..f71a287 --- /dev/null +++ b/classes/deck/logger.gd @@ -0,0 +1,42 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +class_name Logger + +enum LogType { + INFO, + WARN, + ERROR, +} + +enum LogCategory { + NODE, + DECK, + SYSTEM, + RENDERER, +} + +signal log_message(text: String, type: LogType, category: LogCategory) + + +func log_node(text: Variant, type: LogType = LogType.INFO) -> void: + self.log(str(text), type, LogCategory.NODE) + + +func log_deck(text: Variant, type: LogType = LogType.INFO) -> void: + self.log(str(text), type, LogCategory.DECK) + + +func log_system(text: Variant, type: LogType = LogType.INFO) -> void: + self.log(str(text), type, LogCategory.SYSTEM) + + +func log_renderer(text: Variant, type: LogType = LogType.INFO) -> void: + self.log(str(text), type, LogCategory.RENDERER) + + +func log(text: String, type: LogType, category: LogCategory) -> void: + log_message.emit(text, type, category) + + if OS.has_feature("editor"): + prints(LogType.keys()[type].capitalize(), LogCategory.keys()[category].capitalize(), text) diff --git a/classes/deck/node_db.gd b/classes/deck/node_db.gd index b9a1d5f..7ca92e0 100644 --- a/classes/deck/node_db.gd +++ b/classes/deck/node_db.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends Node class_name NodeDB_ @@ -29,7 +32,7 @@ func _init() -> void: dir.list_dir_begin() var current_file := dir.get_next() while current_file != "": - print(current_file) + #print(current_file) if current_file.ends_with(".gd"): var script_path := BASE_NODE_PATH.path_join(current_file) var node: DeckNode = load(script_path).new() as DeckNode @@ -74,12 +77,12 @@ func save_node_index() -> void: func load_node_index() -> bool: var f := FileAccess.open(NODE_INDEX_CACHE_PATH, FileAccess.READ) if f == null: - print("node index file does not exist") + DeckHolder.logger.log_system("node index file does not exist", Logger.LogType.ERROR) return false var data: Dictionary = JSON.parse_string(f.get_as_text()) as Dictionary if data.is_empty(): - print("node index file exists, but is empty") + DeckHolder.logger.log_system("node index file exists, but is empty", Logger.LogType.ERROR) return false for node_type in data: @@ -87,7 +90,7 @@ func load_node_index() -> bool: var nd := NodeDescriptor.from_dictionary(nd_dict) nodes[node_type] = nd - print("node index file exists, loaded") + DeckHolder.logger.log_system("node index file exists, loaded") return true ## Sets a specific [member DeckNode.node_type] to be a "favorite" for use in diff --git a/classes/deck/nodes/bool_constant.gd b/classes/deck/nodes/bool_constant.gd new file mode 100644 index 0000000..52e23f5 --- /dev/null +++ b/classes/deck/nodes/bool_constant.gd @@ -0,0 +1,24 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Bool Constant" + node_type = name.to_snake_case() + category = "general" + description = "A checkbox." + + add_output_port( + DeckType.Types.BOOL, + "Value", + "checkbox" + ) + + +func _value_request(_from_port: int) -> Variant: + if ports[0].value_callback.get_object(): + return ports[0].value_callback.call() + else: + return ports[0].value diff --git a/classes/deck/nodes/button.gd b/classes/deck/nodes/button.gd index e87fa0d..c080070 100644 --- a/classes/deck/nodes/button.gd +++ b/classes/deck/nodes/button.gd @@ -1,10 +1,13 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode func _init() -> void: name = "Button" node_type = "button" - description = "a button" + description = "A button to trigger certain nodes that have a trigger input." category = "general" add_output_port( diff --git a/classes/deck/nodes/delay.gd b/classes/deck/nodes/delay.gd index 1bea237..865c756 100644 --- a/classes/deck/nodes/delay.gd +++ b/classes/deck/nodes/delay.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode var thread : Thread @@ -5,39 +8,39 @@ var thread : Thread func _init(): name = "Delay" node_type = name.to_snake_case() - description = "A Node that passes through the input after the set time." + description = "A node that passes through its' input after the set time." category = "general" - - add_output_port(DeckType.Types.STRING, "Value", "") - + + add_output_port(DeckType.Types.ANY, "Value") + add_input_port(DeckType.Types.NUMERIC, "Delay Time", "field") - add_input_port(DeckType.Types.NUMERIC, "Value", "field") - + add_input_port(DeckType.Types.ANY, "Value", "field") -func _receive(to_input_port : int, data: Variant, extra_data: Array = []) -> void: - + +func _receive(_to_input_port : int, data: Variant, _extra_data: Array = []) -> void: + thread = Thread.new() thread.start(handle_delay.bind(data)) - + func handle_delay(data): - + var goal_time = Time.get_ticks_msec() + (int(get_input_ports()[0].value_callback.call()) * 1000) - + while Time.get_ticks_msec() < goal_time: - + pass - - - print("Delay over") + + + #print("Delay over") send(0, data) - + func _notification(what): - + if what == NOTIFICATION_PREDELETE and thread != null: - + thread.wait_to_finish() - - + + diff --git a/classes/deck/nodes/dictionary_get_key.gd b/classes/deck/nodes/dictionary_get_key.gd new file mode 100644 index 0000000..92070dd --- /dev/null +++ b/classes/deck/nodes/dictionary_get_key.gd @@ -0,0 +1,39 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Get Dictionary Key" + node_type = "dictionary_get_key" + description = "Returns the value of a key from a dictionary input, if it exists, or null otherwise." + category = "general" + + add_input_port( + DeckType.Types.DICTIONARY, + "Dictionary" + ) + + add_input_port( + DeckType.Types.STRING, + "Key", + "field" + ) + + add_output_port( + DeckType.Types.ANY, + "Value" + ) + + +func _value_request(_on_port: int) -> Variant: + var d = request_value(0) + if d == null: + return null + + var key = resolve_input_port_value(1) + if key == null: + return null + + return d.get(key) diff --git a/classes/deck/nodes/expression_node.gd b/classes/deck/nodes/expression_node.gd index ce2bdac..1c7298b 100644 --- a/classes/deck/nodes/expression_node.gd +++ b/classes/deck/nodes/expression_node.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode var expr = Expression.new() @@ -5,32 +8,45 @@ var expr = Expression.new() func _init(): name = "Expression" node_type = name.to_snake_case() - description = "A Node holding a block of executable GDScript code." + description = "A node returning the result of a mathematical expression." category = "general" props_to_serialize = [] - - add_output_port(DeckType.Types.ANY, "Expression Text", "codeblock") - + + # TODO: order of ports is changed + # due to https://github.com/godotengine/godot/issues/85558 + # when it's fixed, switch it back + add_input_port( + DeckType.Types.DICTIONARY, + "Expression Input" + ) + + add_output_port( + DeckType.Types.ANY, + "Expression Text", + "codeblock" + ) -func _value_request(from_port : int) -> Variant: - + +func _value_request(_from_port : int) -> Variant: + var text = get_output_ports()[0].value_callback.call() - - var err = expr.parse(text) + + var err = expr.parse(text, ["deck_var", "input"]) if err != OK: - + + DeckHolder.logger.log_node("Expression parse failed: %s" % err, Logger.LogType.ERROR) printerr(err) return null - - - var res = expr.execute() + + + var res = expr.execute([_belonging_to.variable_stack, request_value(0)]) if expr.has_execute_failed(): - - printerr("Expression Execution Failed: ", text) + + DeckHolder.logger.log_node("Expression Execution Failed: %s" % text, Logger.LogType.ERROR) return null - - + + return res - + diff --git a/classes/deck/nodes/get_deck_var.gd b/classes/deck/nodes/get_deck_var.gd index 44686f9..168be2d 100644 --- a/classes/deck/nodes/get_deck_var.gd +++ b/classes/deck/nodes/get_deck_var.gd @@ -1,19 +1,22 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode func _init() -> void: name = "Get Deck Var" node_type = name.to_snake_case() - description = "retrieve a deck variable" + description = "Retrieve a deck variable." category = "general" add_output_port( - DeckType.Types.STRING, + DeckType.Types.ANY, "Variable", "field" ) -func _value_request(from_port: int) -> Variant: +func _value_request(_from_port: int) -> Variant: var key = ports[0].value_callback.call() - return _belonging_to.variable_stack[key] + return _belonging_to.variable_stack.get(key) diff --git a/classes/deck/nodes/group_input_node.gd b/classes/deck/nodes/group_input_node.gd index e2f4dad..1be0115 100644 --- a/classes/deck/nodes/group_input_node.gd +++ b/classes/deck/nodes/group_input_node.gd @@ -1,15 +1,21 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode var output_count: int: get: return get_all_ports().size() - 1 +var group_node: DeckNode + func _init() -> void: name = "Group input" node_type = "group_input" props_to_serialize = [&"output_count"] appears_in_search = false + user_can_delete = false add_output_port( DeckType.Types.ANY, @@ -31,11 +37,14 @@ func _on_outgoing_connection_added(port_idx: int) -> void: func _on_outgoing_connection_removed(port_idx: int) -> void: var last_connected_port := 0 + #for port: int in outgoing_connections.keys().slice(1): + #if !(outgoing_connections[port] as Array).is_empty(): + #last_connected_port = port for port: int in outgoing_connections.keys().slice(1): - if !(outgoing_connections[port] as Array).is_empty(): + if !(outgoing_connections.get(port, {}) as Dictionary).is_empty(): last_connected_port = port - prints("l:", last_connected_port, "p:", port_idx) + #prints("l:", last_connected_port, "p:", port_idx) if port_idx < last_connected_port: return @@ -68,5 +77,4 @@ func _post_load() -> void: func _value_request(from_port: int) -> Variant: - var group_node := _belonging_to._belonging_to.get_node(_belonging_to.group_node) return group_node.request_value(group_node.get_input_ports()[from_port].index_of_type) diff --git a/classes/deck/nodes/group_node.gd b/classes/deck/nodes/group_node.gd index d2ba8e8..c09a21a 100644 --- a/classes/deck/nodes/group_node.gd +++ b/classes/deck/nodes/group_node.gd @@ -1,22 +1,28 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode var group_id: String +var group_instance_id: String var input_node: DeckNode var output_node: DeckNode +var input_node_id: String +var output_node_id: String + var extra_ports: Array func _init() -> void: name = "Group" node_type = "group_node" - props_to_serialize = [&"group_id", &"extra_ports"] + props_to_serialize = [&"group_id", &"group_instance_id", &"extra_ports", &"input_node_id", &"output_node_id"] appears_in_search = false func _pre_connection() -> void: for port_type: PortType in extra_ports: - var index_of_type: int match port_type: PortType.OUTPUT: add_output_port(DeckType.Types.ANY, "Output %s" % get_output_ports().size()) @@ -25,10 +31,16 @@ func _pre_connection() -> void: func init_io() -> void: - var group: Deck = _belonging_to.groups.get(group_id) as Deck + #var group: Deck = _belonging_to.groups.get(group_id) as Deck + var group := DeckHolder.get_group_instance(group_id, group_instance_id) if !group: return + if input_node && input_node.ports_updated.is_connected(recalculate_ports): + input_node.ports_updated.disconnect(recalculate_ports) + if output_node && output_node.ports_updated.is_connected(recalculate_ports): + output_node.ports_updated.disconnect(recalculate_ports) + input_node = group.get_node(group.group_input_node) output_node = group.get_node(group.group_output_node) diff --git a/classes/deck/nodes/group_output_node.gd b/classes/deck/nodes/group_output_node.gd index 477d90b..76815a9 100644 --- a/classes/deck/nodes/group_output_node.gd +++ b/classes/deck/nodes/group_output_node.gd @@ -1,15 +1,21 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode var input_count: int: get: return get_all_ports().size() - 1 +var group_node: DeckNode + func _init() -> void: name = "Group output" node_type = "group_output" props_to_serialize = [&"input_count"] appears_in_search = false + user_can_delete = false add_input_port( DeckType.Types.ANY, @@ -36,7 +42,7 @@ func _on_incoming_connection_removed(port_idx: int) -> void: if !(incoming_connections[port] as Dictionary).is_empty(): last_connected_port = port - prints("l:", last_connected_port, "p:", port_idx) + #prints("l:", last_connected_port, "p:", port_idx) if port_idx < last_connected_port: return @@ -69,5 +75,4 @@ func _post_load() -> void: func _receive(to_input_port: int, data: Variant, extra_data: Array = []) -> void: - var group_node := _belonging_to._belonging_to.get_node(_belonging_to.group_node) group_node.send(group_node.get_output_ports()[to_input_port].index_of_type, data, extra_data) diff --git a/classes/deck/nodes/if_true.gd b/classes/deck/nodes/if_true.gd new file mode 100644 index 0000000..3348710 --- /dev/null +++ b/classes/deck/nodes/if_true.gd @@ -0,0 +1,38 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Pass If True" + node_type = "if_true" + description = "Pass input if and only if the condition input is true." + category = "general" + + add_input_port( + DeckType.Types.BOOL, + "Condition", + "checkbox" + ) + add_input_port( + DeckType.Types.ANY, + "Input" + ) + + add_output_port( + DeckType.Types.ANY, + "Output" + ) + + +func _receive(to_input_port: int, data: Variant, extra_data: Array = []) -> void: + if to_input_port != 1: + return + + if resolve_input_port_value(0): + send(0, data, extra_data) + elif ports[0].value: + send(0, data, extra_data) + + diff --git a/classes/deck/nodes/obs_decompose_transform.gd b/classes/deck/nodes/obs_decompose_transform.gd new file mode 100644 index 0000000..f74ed7d --- /dev/null +++ b/classes/deck/nodes/obs_decompose_transform.gd @@ -0,0 +1,46 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Decompose OBS Transform" + node_type = "obs_decompose_transform" + description = "Splits an OBS transform from one object into multiple outputs." + category = "obs" + + add_input_port( + DeckType.Types.DICTIONARY, + "Transform" + ) + + add_output_port( + DeckType.Types.NUMERIC, + "Rotation" + ) + + add_output_port( + DeckType.Types.NUMERIC, + "Position X" + ) + add_output_port( + DeckType.Types.NUMERIC, + "Position Y" + ) + + +func _value_request(on_port: int) -> Variant: + var t = request_value(0) + if t == null: + return null + + match on_port: + 0: + return (t as Dictionary).get("rotation") + 1: + return (t as Dictionary).get("position_x") + 2: + return (t as Dictionary).get("position_y") + _: + return null diff --git a/classes/deck/nodes/obs_scene_list.gd b/classes/deck/nodes/obs_scene_list.gd new file mode 100644 index 0000000..5ac822e --- /dev/null +++ b/classes/deck/nodes/obs_scene_list.gd @@ -0,0 +1,53 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + +var noobs: NoOBSWS + + +func _init() -> void: + name = "Scene Selector" + node_type = "obs_scene_list" + description = "" + category = "obs" + + props_to_serialize = [] + + add_output_port( + DeckType.Types.STRING, + "Select a scene", + "singlechoice" + ) + + add_virtual_port( + DeckType.Types.BOOL, + "Refresh", + "button" + ) + + +func _receive(on_virtual_port: int, _data: Variant, _extra_data: Array = []) -> void: + if on_virtual_port != 0: + return + + if noobs == null: + noobs = Connections.obs_websocket + + var items: Array + var req := noobs.make_generic_request("GetSceneList") + await req.response_received + items = req.message.response_data.scenes + var new_desc := "singlechoice" + for scene in items: + new_desc += ":" + scene.sceneName + new_desc.trim_suffix(":") + get_output_ports()[0].descriptor = new_desc + ports_updated.emit() + + +func _value_request(_on_input_port: int) -> Variant: + if ports[0].value != null: + return ports[0].value + + return null diff --git a/classes/deck/nodes/obs_search_source.gd b/classes/deck/nodes/obs_search_source.gd new file mode 100644 index 0000000..1c50cc2 --- /dev/null +++ b/classes/deck/nodes/obs_search_source.gd @@ -0,0 +1,78 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + +var noobs: NoOBSWS + +var cached_scene_name: String +var cached_source_name: String +var cached_id # TODO: evaluate if this caching is actually needed + + +func _init() -> void: + name = "Get Source ID" + node_type = "obs_search_source" + description = "Searches for an OBS source in a scene and returns its output." + category = "obs" + + add_input_port( + DeckType.Types.STRING, + "Scene name", + "field" + ) + + add_input_port( + DeckType.Types.STRING, + "Source name", + "field" + ) + + add_output_port( + DeckType.Types.NUMERIC, + "Source ID" + ) + + +func _value_request(_on_output_port: int) -> Variant: + if noobs == null: + noobs = Connections.obs_websocket + + var scene_name: String + if request_value(0) != null: + scene_name = request_value(0) + elif get_input_ports()[0].value_callback.get_object() && get_input_ports()[0].value_callback.call() != "": + scene_name = get_input_ports()[0].value_callback.call() + if scene_name.is_empty(): + return null + + var source_name: String + if request_value(1) != null: + source_name = request_value(1) + elif get_input_ports()[1].value_callback.get_object() && get_input_ports()[1].value_callback.call() != "": + source_name = get_input_ports()[1].value_callback.call() + if source_name.is_empty(): + return null + + if cached_scene_name == scene_name && cached_source_name == scene_name: + return cached_id + + cached_scene_name = scene_name + cached_source_name = source_name + + var req := noobs.make_generic_request( + "GetSceneItemId", + { + "scene_name": scene_name, + "source_name": source_name + } + ) + await req.response_received + + var data := req.message.get_data() + #if int(data.request_status.code) != NoOBSWS.Enums.RequestStatus.NO_ERROR: + #return null + cached_id = data.response_data.scene_item_id + + return data.response_data.scene_item_id + diff --git a/classes/deck/nodes/obs_set_source_transform.gd b/classes/deck/nodes/obs_set_source_transform.gd new file mode 100644 index 0000000..525d71f --- /dev/null +++ b/classes/deck/nodes/obs_set_source_transform.gd @@ -0,0 +1,108 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + +var noobs: NoOBSWS + +var lock := false # TODO: evaluate if this locking is actually needed + +enum InputPorts{ + SCENE_NAME, + SOURCE_ID, + XFORM, + SET, +} + + +func _init() -> void: + name = "Set Source Transform" + node_type = "obs_set_source_transform" + description = "Sets an OBS source's transform, which includes position and rotation, among other things." + category = "obs" + + props_to_serialize = [] + + add_input_port( + DeckType.Types.STRING, + "Scene name", + "field" + ) + + add_input_port( + DeckType.Types.NUMERIC, + "Source ID", + "field" + ) + + add_input_port( + DeckType.Types.DICTIONARY, + "Transform" + ) + + add_input_port( + DeckType.Types.BOOL, + "Set", + "button" + ) + + +func _receive(to_input_port: int, _data: Variant, _extra_data: Array = []) -> void: +#{ "scene_item_transform": { "alignment": 5, "bounds_alignment": 0, "bounds_height": 0, "bounds_type": "OBS_BOUNDS_NONE", "bounds_width": 0, "crop_bottom": 0, "crop_left": 0, "crop_right": 0, "crop_top": 0, "height": 257, "position_x": 1800, "position_y": 414, "rotation": 0, "scale_x": 1, "scale_y": 1, "source_height": 257, "source_width": 146, "width": 146 }} + if to_input_port != 3: + return + + if noobs == null: + noobs = Connections.obs_websocket + + if lock: + return + + var scene_name: String + if request_value(InputPorts.SCENE_NAME) != null: + scene_name = request_value(InputPorts.SCENE_NAME) + elif get_input_ports()[InputPorts.SCENE_NAME].value_callback.get_object() && get_input_ports()[InputPorts.SCENE_NAME].value_callback.call() != "": + scene_name = get_input_ports()[InputPorts.SCENE_NAME].value_callback.call() + + if scene_name.is_empty(): + return + + var source_id: float + var res_as = await request_value_async(InputPorts.SOURCE_ID) + if res_as != null: + source_id = res_as + elif get_input_ports()[InputPorts.SOURCE_ID].value_callback.get_object() && get_input_ports()[InputPorts.SOURCE_ID].value_callback.call() != null: + source_id = get_input_ports()[InputPorts.SOURCE_ID].value_callback.call() + + var xform: Dictionary + if request_value(InputPorts.XFORM) != null: + xform = request_value(InputPorts.XFORM) + elif get_input_ports()[InputPorts.XFORM].value_callback.get_object() && get_input_ports()[InputPorts.XFORM].value_callback.call() != null: + xform = get_input_ports()[InputPorts.XFORM].value_callback.call() + + if xform.is_empty(): + return + + lock = true + + #noobs.make_generic_request("SetSceneItemTransform", + #{ + #"scene_name": scene_name, + #"scene_item_id": source_id, + #"scene_item_transform": xform, + #}) + #var sleep := noobs.make_generic_request("Sleep", {"sleep_frames": 1}) + #sleep.response_received.connect(func(): lock = false) + var b := noobs.make_batch_request(false, NoOBSWS.Enums.RequestBatchExecutionType.SERIAL_FRAME) + b.add_request( + "SetSceneItemTransform", + "", + { + "scene_name": scene_name, + "scene_item_id": source_id, + "scene_item_transform": xform, + } + ) + b.add_request("Sleep", "", {"sleep_frames": 1}) + b.response_received.connect(func(): lock = false) + b.send() diff --git a/classes/deck/nodes/obs_switch_scene.gd b/classes/deck/nodes/obs_switch_scene.gd new file mode 100644 index 0000000..ef63caa --- /dev/null +++ b/classes/deck/nodes/obs_switch_scene.gd @@ -0,0 +1,48 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + +var noobs: NoOBSWS + +func _init() -> void: + name = "Set Scene" + node_type = "obs_set_scene" + description = "Sets the current scene in OBS." + category = "obs" + + props_to_serialize = [] + + add_input_port( + DeckType.Types.STRING, + "Scene Name", + "field" + ) + + add_input_port( + DeckType.Types.BOOL, + "Switch", + "button" + ) + + +func _receive(on_input_port: int, _data: Variant, _extra_data: Array = []) -> void: + if on_input_port != 1: + return + + if noobs == null: + noobs = Connections.obs_websocket + + var scene_name: String + if request_value(0) != null: + scene_name = request_value(0) + elif get_input_ports()[0].value_callback.get_object() && get_input_ports()[0].value_callback.call() != "": + scene_name = get_input_ports()[0].value_callback.call() + + if scene_name.is_empty(): + return + + noobs.make_generic_request( + "SetCurrentProgramScene", + {"scene_name": scene_name} + ) diff --git a/classes/deck/nodes/obs_vector_to_position.gd b/classes/deck/nodes/obs_vector_to_position.gd new file mode 100644 index 0000000..e6512bf --- /dev/null +++ b/classes/deck/nodes/obs_vector_to_position.gd @@ -0,0 +1,31 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Vector to OBS Position" + node_type = "obs_vector_to_position" + description = "Transforms a Vector into a position vector accepted by OBS transform inputs." + category = "obs" + + add_input_port( + DeckType.Types.DICTIONARY, + "Vector" + ) + add_output_port( + DeckType.Types.DICTIONARY, + "Position" + ) + + +func _value_request(_on_port: int) -> Variant: + var v = request_value(0) + if !v: + return null + + if !(v as Dictionary).has("x") || !(v as Dictionary).has("y"): + return null + + return {"position_x": v.x, "position_y": v.y} diff --git a/classes/deck/nodes/obs_websocket_generic_request.gd b/classes/deck/nodes/obs_websocket_generic_request.gd new file mode 100644 index 0000000..95c5b74 --- /dev/null +++ b/classes/deck/nodes/obs_websocket_generic_request.gd @@ -0,0 +1,62 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + +var noobs: NoOBSWS + + +func _init() -> void: + name = "OBS WS Generic Request" + node_type = "obs_generic_request" + description = "Makes an OBS request and sends its result through. Use if if you really know what you're doing." + category = "obs" + + props_to_serialize = [] + + add_virtual_port( + DeckType.Types.STRING, + "Request type", + "field" + ) + + add_virtual_port( + DeckType.Types.STRING, + "Request data", + "codeblock" + ) + + add_output_port( + DeckType.Types.DICTIONARY, + "Result" + ) + + add_virtual_port( + DeckType.Types.BOOL, + "Request", + "button" + ) + + +func _receive(on_virtual_port: int, _data: Variant, _extra_data: Array = []) -> void: + if on_virtual_port != 2: + return + + if noobs == null: + noobs = Connections.obs_websocket + + print(get_virtual_ports()[1].value) + var e: Dictionary = type_convert(get_virtual_ports()[1].value, TYPE_DICTIONARY) + print(e) + if typeof(e) != TYPE_DICTIONARY: + return + + var req := noobs.make_generic_request( + get_virtual_ports()[0].value, + str_to_var(get_virtual_ports()[1].value) + ) + + await req.response_received + + var d := req.message.get_data() + send(0, d) diff --git a/classes/deck/nodes/print.gd b/classes/deck/nodes/print.gd index 7088714..eacfea4 100644 --- a/classes/deck/nodes/print.gd +++ b/classes/deck/nodes/print.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode var times_activated := 0 @@ -6,14 +9,14 @@ var times_activated := 0 func _init() -> void: name = "Print" node_type = name.to_snake_case() - description = "print a value" + description = "Print a value to the console." props_to_serialize = [&"times_activated"] category = "general" add_input_port( - DeckType.Types.STRING, - "Text to print", + DeckType.Types.ANY, + "Data to print", "field" ) @@ -34,17 +37,18 @@ func _receive(to_input_port: int, data: Variant, extra_data: Array = []) -> void if to_input_port != 1: return - var data_to_print - if request_value(0) != null: - data_to_print = request_value(0) - elif get_input_ports()[0].value_callback.get_object() && get_input_ports()[0].value_callback.call() != "": - data_to_print = get_input_ports()[0].value_callback.call() - else: - data_to_print = data + var data_to_print = str(resolve_input_port_value(0)) + if data_to_print == null: + data_to_print = str(data) + if (data_to_print as String).is_empty(): + data_to_print = "" times_activated += 1 # var data_to_print = input_ports[0].value_callback.call() - print(data_to_print) - print("extra data: ", extra_data) + #print(data_to_print) + #print("extra data: ", extra_data) + DeckHolder.logger.log_node(data_to_print) + if !extra_data.is_empty(): + DeckHolder.logger.log_node(str("Extra data: ", extra_data)) send(0, true) diff --git a/classes/deck/nodes/process_node.gd b/classes/deck/nodes/process_node.gd new file mode 100644 index 0000000..3258be5 --- /dev/null +++ b/classes/deck/nodes/process_node.gd @@ -0,0 +1,49 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + +var delta: float + + +func _init() -> void: + name = "Process Loop" + node_type = name.to_snake_case() + description = "Sends a trigger output every frame, and returns the delta value (time since last frame in seconds)." + category = "general" + + add_input_port( + DeckType.Types.BOOL, + "Enabled", + "checkbox" + ) + + add_output_port( + DeckType.Types.BOOL, + "Trigger" + ) + + add_output_port( + DeckType.Types.NUMERIC, + "Delta" + ) + + +func _event_received(event_name: StringName, event_data: Dictionary = {}) -> void: + if event_name != &"process": + return + + var run = resolve_input_port_value(0) == true + + if !run: + return + + delta = event_data.delta + send(0, true) + + +func _value_request(on_output_port: int) -> Variant: + if on_output_port != 1: + return null + + return delta diff --git a/classes/deck/nodes/set_deck_var.gd b/classes/deck/nodes/set_deck_var.gd index 66ae632..40f383f 100644 --- a/classes/deck/nodes/set_deck_var.gd +++ b/classes/deck/nodes/set_deck_var.gd @@ -1,10 +1,13 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode func _init() -> void: name = "Set Deck Var" node_type = name.to_snake_case() - description = "set deck variable" + description = "Set a deck variable on trigger." category = "general" add_input_port( @@ -32,12 +35,12 @@ func _init() -> void: ) -func _receive(to_input_port: int, data: Variant, extra_data: Array = []) -> void: +func _receive(to_input_port: int, _data: Variant, _extra_data: Array = []) -> void: if to_input_port != 2: return - var var_name: String = get_value_for_port(0, data) - var var_value: Variant = get_value_for_port(1, data) + var var_name: String = resolve_input_port_value(0) + var var_value: Variant = resolve_input_port_value(1) _belonging_to.variable_stack[var_name] = var_value @@ -45,10 +48,11 @@ func _receive(to_input_port: int, data: Variant, extra_data: Array = []) -> void # this can probably go into DeckNode with a different name that makes it clear # that it prioritizes call-time resolution -func get_value_for_port(port: int, data: Variant) -> Variant: +# EDIT: done, see DeckNode#resolve_input_port_value +func get_value_for_port(port: int) -> Variant: if request_value(port) != null: return request_value(port) - elif ports[port].value_callback.call() != "": - return ports[port].value_callback.call() + elif get_input_ports()[port].value_callback.get_object() && get_input_ports()[port].value_callback.call() != null: + return get_input_ports()[port].value_callback.call() else: - return data + return null diff --git a/classes/deck/nodes/string_constant.gd b/classes/deck/nodes/string_constant.gd index 9fab8bc..78e3a0b 100644 --- a/classes/deck/nodes/string_constant.gd +++ b/classes/deck/nodes/string_constant.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode @@ -5,6 +8,7 @@ func _init() -> void: node_type = "string_constant" name = "String Constant" category = "general" + description = "A String field." add_output_port( DeckType.Types.STRING, @@ -12,7 +16,7 @@ func _init() -> void: "field" ) -func _value_request(from_port: int) -> Variant: +func _value_request(_from_port: int) -> Variant: if ports[0].value_callback.get_object(): return ports[0].value_callback.call() else: diff --git a/classes/deck/nodes/test_interleaved_node.gd b/classes/deck/nodes/test_interleaved_node.gd index 5ecbedf..dab7d0b 100644 --- a/classes/deck/nodes/test_interleaved_node.gd +++ b/classes/deck/nodes/test_interleaved_node.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode diff --git a/classes/deck/nodes/test_types.gd b/classes/deck/nodes/test_types.gd index d24a5d6..ca1883e 100644 --- a/classes/deck/nodes/test_types.gd +++ b/classes/deck/nodes/test_types.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode @@ -26,7 +29,7 @@ func _init() -> void: add_input_port(DeckType.Types.BOOL, "Send", "button") -func _receive(to_input_port: int, data: Variant, extra_data: Array = []) -> void: +func _receive(to_input_port: int, data: Variant, _extra_data: Array = []) -> void: if to_input_port == 6: send(0, false) send(1, 1.0) diff --git a/classes/deck/nodes/twitch_chat_received.gd b/classes/deck/nodes/twitch_chat_received.gd new file mode 100644 index 0000000..d3aa1be --- /dev/null +++ b/classes/deck/nodes/twitch_chat_received.gd @@ -0,0 +1,56 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + +var username := "" +var message := "" +var channel := "" +var tags := {} + + +func _init(): + name = "Twitch Chat Received" + node_type = "twitch_chat_received" + description = "Receives Twitch chat events from a Twitch connection." + category = "twitch" + + add_output_port(DeckType.Types.STRING, "Username") + add_output_port(DeckType.Types.STRING, "Message") + add_output_port(DeckType.Types.STRING, "Channel") + add_output_port(DeckType.Types.DICTIONARY, "Tags") + + add_output_port( + DeckType.Types.BOOL, + "On receive" + ) + + + +func _event_received(event_name : StringName, event_data : Dictionary = {}): + + if event_name != &"twitch_chat": + + return + + + username = event_data.username + message = event_data.message + channel = event_data.channel + tags = event_data + + send(4, true) + + +func _value_request(on_port: int) -> Variant: + match on_port: + 0: + return username + 1: + return message + 2: + return channel + 3: + return tags + _: + return null diff --git a/classes/deck/nodes/twitch_send_chat.gd b/classes/deck/nodes/twitch_send_chat.gd new file mode 100644 index 0000000..f621b4d --- /dev/null +++ b/classes/deck/nodes/twitch_send_chat.gd @@ -0,0 +1,52 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init(): + name = "Twitch Send Chat" + node_type = "twitch_send_chat" + description = "Sends a message to a Twitch chat." + category = "twitch" + + add_input_port( + DeckType.Types.STRING, + "Message", + "field" + ) + add_input_port( + DeckType.Types.STRING, + "Channel", + "field" + ) + + add_input_port( + DeckType.Types.BOOL, + "Send", + "button" + ) + + + +func _receive(to_input_port, data: Variant, extra_data: Array = []): + + if to_input_port != 2: + + return + + + var msg = resolve_input_port_value(0) + var channel = resolve_input_port_value(1) + + if channel == null: + + channel = "" + + if msg == null: + + return + + + Connections.twitch.send_chat(msg, channel) + diff --git a/classes/deck/nodes/vector_add.gd b/classes/deck/nodes/vector_add.gd new file mode 100644 index 0000000..876424b --- /dev/null +++ b/classes/deck/nodes/vector_add.gd @@ -0,0 +1,47 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Add Vectors" + node_type = "vector_add" + description = "Adds two 2D vectors." + category = "math" + + add_input_port( + DeckType.Types.DICTIONARY, + "Vector A" + ) + add_input_port( + DeckType.Types.DICTIONARY, + "Vector B" + ) + add_output_port( + DeckType.Types.DICTIONARY, + "Result" + ) + + +func _value_request(_on_port: int) -> Variant: + var va = request_value(0) + var vb = request_value(1) + + if !va || !vb: + DeckHolder.logger.log_node("Vector Add: one of the vectors is invalid.", Logger.LogType.ERROR) + return null + + if !(va as Dictionary).has("x") || !(va as Dictionary).has("y"): + DeckHolder.logger.log_node("Vector Add: one of the vectors is invalid.", Logger.LogType.ERROR) + return null + + if !(vb as Dictionary).has("x") || !(vb as Dictionary).has("y"): + DeckHolder.logger.log_node("Vector Add: one of the vectors is invalid.", Logger.LogType.ERROR) + return null + + var res := {} + res["x"] = va["x"] + vb["x"] + res["y"] = va["y"] + vb["y"] + + return res diff --git a/classes/deck/nodes/vector_compose.gd b/classes/deck/nodes/vector_compose.gd new file mode 100644 index 0000000..c9efd3c --- /dev/null +++ b/classes/deck/nodes/vector_compose.gd @@ -0,0 +1,33 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Compose Vector" + node_type = "vector_compose" + description = "Returns a vector from two numeric inputs." + category = "math" + + add_input_port( + DeckType.Types.NUMERIC, + "X", + "field" + ) + add_input_port( + DeckType.Types.NUMERIC, + "Y", + "field" + ) + + add_output_port( + DeckType.Types.DICTIONARY, + "Vector" + ) + + +func _value_request(_on_port: int) -> Dictionary: + var x = float(resolve_input_port_value(0)) + var y = float(resolve_input_port_value(1)) + return {"x": x, "y": y} diff --git a/classes/deck/nodes/vector_decompose.gd b/classes/deck/nodes/vector_decompose.gd new file mode 100644 index 0000000..0b68607 --- /dev/null +++ b/classes/deck/nodes/vector_decompose.gd @@ -0,0 +1,41 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Decompose Vector" + node_type = "vector_decompose" + description = "Returns the X and Y components of a vector." + category = "math" + + add_input_port( + DeckType.Types.DICTIONARY, + "Vector" + ) + + add_output_port( + DeckType.Types.NUMERIC, + "X" + ) + add_output_port( + DeckType.Types.NUMERIC, + "Y" + ) + + +func _value_request(on_port: int) -> Variant: + var v = request_value(0) + if !v: + DeckHolder.logger.log_node("Vector Decompose: the vector is invalid.", Logger.LogType.ERROR) + return null + + if !(v as Dictionary).has("x") || !(v as Dictionary).has("y"): + DeckHolder.logger.log_node("Vector Decompose: the vector is invalid.", Logger.LogType.ERROR) + return null + + if on_port == 0: + return v.x + else: + return v.y diff --git a/classes/deck/nodes/vector_dot.gd b/classes/deck/nodes/vector_dot.gd new file mode 100644 index 0000000..26d98ca --- /dev/null +++ b/classes/deck/nodes/vector_dot.gd @@ -0,0 +1,44 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Vector Dot Product" + node_type = "vector_dot" + description = "Returns the dot product of two vectors." + category = "math" + + add_input_port( + DeckType.Types.DICTIONARY, + "Vector A" + ) + add_input_port( + DeckType.Types.DICTIONARY, + "Vector B" + ) + + add_output_port( + DeckType.Types.NUMERIC, + "Dot" + ) + + +func _value_request(_on_port: int) -> Variant: + var va = request_value(0) + var vb = request_value(1) + + if !va || !vb: + DeckHolder.logger.log_node("Vector Dot: one of the vectors is invalid.", Logger.LogType.ERROR) + return null + + if !(va as Dictionary).has("x") || !(va as Dictionary).has("y"): + DeckHolder.logger.log_node("Vector Dot: one of the vectors is invalid.", Logger.LogType.ERROR) + return null + + if !(vb as Dictionary).has("x") || !(vb as Dictionary).has("y"): + DeckHolder.logger.log_node("Vector Dot: one of the vectors is invalid.", Logger.LogType.ERROR) + return null + + return va.x * vb.x + va.y * vb.y diff --git a/classes/deck/nodes/vector_multiply.gd b/classes/deck/nodes/vector_multiply.gd new file mode 100644 index 0000000..75d4d35 --- /dev/null +++ b/classes/deck/nodes/vector_multiply.gd @@ -0,0 +1,42 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Multiply Vector by Scalar" + node_type = "vector_multiply" + description = "Multiplies a vector by a numeric value." + category = "math" + + add_input_port( + DeckType.Types.DICTIONARY, + "Vector" + ) + add_input_port( + DeckType.Types.NUMERIC, + "Scalar", + "field" + ) + + add_output_port( + DeckType.Types.DICTIONARY, + "Result" + ) + + +func _value_request(_on_port: int) -> Variant: + var v = request_value(0) + + if !v: + DeckHolder.logger.log_node("Vector Mult: the vector is invalid.", Logger.LogType.ERROR) + return null + + if !(v as Dictionary).has("x") || !(v as Dictionary).has("y"): + DeckHolder.logger.log_node("Vector Mult: the vector is invalid.", Logger.LogType.ERROR) + return null + + var s = float(resolve_input_port_value(1)) + + return {"x": v.x * s, "y": v.y * s} diff --git a/classes/deck/nodes/vector_normalize.gd b/classes/deck/nodes/vector_normalize.gd new file mode 100644 index 0000000..16380fa --- /dev/null +++ b/classes/deck/nodes/vector_normalize.gd @@ -0,0 +1,44 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Normalize Vector" + node_type = "vector_normalize" + description = "Normalizes a vector so its' length (magnitude) is exactly 1." + category = "math" + + add_input_port( + DeckType.Types.DICTIONARY, + "Vector" + ) + + add_output_port( + DeckType.Types.DICTIONARY, + "Result" + ) + + +func _value_request(_on_port: int) -> Variant: + var v = request_value(0) + if !v: + DeckHolder.logger.log_node("Vector Normalize: the vector is invalid.", Logger.LogType.ERROR) + return null + + if !(v as Dictionary).has("x") || !(v as Dictionary).has("y"): + DeckHolder.logger.log_node("Vector Normalize: the vector is invalid.", Logger.LogType.ERROR) + return null + + var l: float = (v.x ** 2.0) + (v.y ** 2.0) + var res := {"x": v.x, "y": v.y} + if l != 0: + l = sqrt(l) + res.x = res.x / l + res.y = res.y / l + return res + + DeckHolder.logger.log_node("Vector Normalize: the vector is length 0. Returning null.", Logger.LogType.ERROR) + + return null diff --git a/classes/deck/nodes/vector_subtract.gd b/classes/deck/nodes/vector_subtract.gd new file mode 100644 index 0000000..d11dc51 --- /dev/null +++ b/classes/deck/nodes/vector_subtract.gd @@ -0,0 +1,47 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends DeckNode + + +func _init() -> void: + name = "Subtract Vectors" + node_type = "vector_subtract" + description = "Subtracts each component of the given vectors." + category = "math" + + add_input_port( + DeckType.Types.DICTIONARY, + "Vector A" + ) + add_input_port( + DeckType.Types.DICTIONARY, + "Vector B" + ) + add_output_port( + DeckType.Types.DICTIONARY, + "Result" + ) + + +func _value_request(_on_port: int) -> Variant: + var va = request_value(0) + var vb = request_value(1) + + if !va || !vb: + DeckHolder.logger.log_node("Vector Sub: one of the vectors is invalid.", Logger.LogType.ERROR) + return null + + if !(va as Dictionary).has("x") || !(va as Dictionary).has("y"): + DeckHolder.logger.log_node("Vector Sub: one of the vectors is invalid.", Logger.LogType.ERROR) + return null + + if !(vb as Dictionary).has("x") || !(vb as Dictionary).has("y"): + DeckHolder.logger.log_node("Vector Sub: one of the vectors is invalid.", Logger.LogType.ERROR) + return null + + var res := {} + res["x"] = va["x"] - vb["x"] + res["y"] = va["y"] - vb["y"] + + return res diff --git a/classes/deck/port.gd b/classes/deck/port.gd index 77df898..110fcbc 100644 --- a/classes/deck/port.gd +++ b/classes/deck/port.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) class_name Port ## A data type representing a port of a [DeckNode]. ## @@ -25,7 +28,9 @@ var index_of_type: int var index: int ## The value of this port. -var value: Variant: set = set_value +var value: Variant + +signal value_updated(new_value: Variant) func _init( @@ -48,7 +53,13 @@ func _init( func set_value(v: Variant) -> void: + set_value_no_signal(v) + value_updated.emit(value) + + +func set_value_no_signal(v: Variant) -> void: if v is Callable: value = v.call() return + value = v diff --git a/classes/deck/renderer_persistence.gd b/classes/deck/renderer_persistence.gd new file mode 100644 index 0000000..1b9ca97 --- /dev/null +++ b/classes/deck/renderer_persistence.gd @@ -0,0 +1,140 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +class_name RendererPersistence +## An interface for per-renderer persistent data. +## +## Allows renderers to store key/value data, split into namespaces (usually the renderer name) +## and channels (files on a filesystem). For every namespace, a folder is created, +## and populated with JSON files named after the channel name. + + +const _BASE_PATH := "user://renderer_persistence/" +const _FILE_TEMPLATE := "{namespace}/{channel}.json" + +# Dictionary[String -> namespace, Dictionary[String -> channel, Variant]] +static var _data: Dictionary + +#region API +## Initializes a namespace. Returns [code]false[/code] if the folder for the namespace didn't exist +## before, useful for initializing defaults. +static func init_namespace(name_space: String) -> bool: + var n := name_space.validate_filename() + if _data.get(n) != null: + return true + + var err := _create_namespace_folder(n) + if err != OK: + DeckHolder.logger.log_system("Could not create namespace: error %s" % err, Logger.LogType.ERROR) + return false + _data[name_space] = {} + return false + + +## Returns the corresponding value for the given [param key] in the [param channel] storage. +## If the key does not exist, returns [param default], or [code]null[/code] if the parameter is omitted. +static func get_value(name_space: String, channel: String, key: String, default: Variant = null) -> Variant: + _lazy_load_channel(name_space, channel) + var validated_name := name_space.validate_filename() + var validated_channel := channel.validate_filename() + + return (_data[validated_name][validated_channel] as Dictionary).get(key, default) + + +## Sets the value in the [param channel] storage at [param key] to [param value]. +static func set_value(name_space: String, channel: String, key: String, value: Variant) -> void: + _lazy_load_channel(name_space, channel) + var validated_name := name_space.validate_filename() + var validated_channel := channel.validate_filename() + + _data[validated_name][validated_channel][key] = value + + +## Returns the corresponding value for the given [param key] in the [param channel] storage. +## If the key does not exist, creates it in the storage, initializes it to [param default] +## and returns it. +static func get_or_create(name_space: String, channel: String, key: String, default: Variant) -> Variant: + _lazy_load_channel(name_space, channel) + var validated_name := name_space.validate_filename() + var validated_channel := channel.validate_filename() + + var channel_data: Dictionary = _data[validated_name][validated_channel] + return _get_or_create(channel_data, key, default) + + +## Erases an entry in the store by [param key] and returns its previous value, +## or [code]null[/code] if it didn't exist. +static func erase(name_space: String, channel: String, key: String) -> Variant: + _lazy_load_channel(name_space, channel) + var validated_name := name_space.validate_filename() + var validated_channel := channel.validate_filename() + + var channel_data: Dictionary = _data[validated_name][validated_channel] + var ret = channel_data.get(key) + channel_data.erase(key) + + return ret + + +## Commits the [param channel] to the filesystem. If [param channel] is empty, +## commits all channels in the [param name_space]. +static func commit(name_space: String, channel: String = "") -> void: + if !channel.is_empty(): + _commit_channel(name_space, channel) + else: + for c: String in _data[name_space]: + _commit_channel(name_space, c) + +#endregion + + +static func _get_or_create(d: Dictionary, key: String, default: Variant) -> Variant: + var r = d.get(key) + if r == null: + r = default + d[key] = r + + return r + + +static func _create_namespace_folder(name_space: String) -> Error: + return DirAccess.make_dir_recursive_absolute(_BASE_PATH.path_join(name_space)) + + +static func _lazy_load_channel(name_space: String, channel: String) -> void: + var validated_name := name_space.validate_filename() + var validated_channel := channel.validate_filename() + if !_data.has(validated_name): + DeckHolder.logger.log_system("Namespace %s is not initialized" % name_space, Logger.LogType.ERROR) + return + + if (_data[validated_name] as Dictionary).has(validated_channel): + return + + var path := _BASE_PATH.path_join(_FILE_TEMPLATE.format( + { + "namespace": validated_name, + "channel": validated_channel + })) + + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + _data[validated_name][validated_channel] = {} + return + + var data = JSON.parse_string(f.get_as_text()) + _data[validated_name][validated_channel] = data + + +static func _commit_channel(name_space: String, channel: String) -> void: + _lazy_load_channel(name_space, channel) + var validated_name := name_space.validate_filename() + var validated_channel := channel.validate_filename() + + var path := _BASE_PATH.path_join(_FILE_TEMPLATE.format( + { + "namespace": validated_name, + "channel": validated_channel + })) + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string(JSON.stringify(_data[validated_name][validated_channel], "\t", false)) diff --git a/classes/deck/search_provider.gd b/classes/deck/search_provider.gd index 1445001..b207758 100644 --- a/classes/deck/search_provider.gd +++ b/classes/deck/search_provider.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) class_name SearchProvider ## A class facilitating the searching of nodes. @@ -30,7 +33,7 @@ static var filters: Array[Filter] = [ func(element: NodeDB.NodeDescriptor, _search_string: String, pre_strip_string: String) -> bool: const p := r"#c\s[\w]+" var r := RegEx.create_from_string(p) - print("pre: ", pre_strip_string) + #print("pre: ", pre_strip_string) var c := r.search(pre_strip_string).get_string().split("#c ", false)[0] return c.is_subsequence_ofn(element.category), @@ -39,7 +42,7 @@ static var filters: Array[Filter] = [ const p := r"#c\s[\w]+" var r := RegEx.create_from_string(p) var c := r.search(search_string).get_string() - prints("c:", c, "r:", search_string.replace(c, "")) + #prints("c:", c, "r:", search_string.replace(c, "")) return search_string.replace(c, "") ), ] diff --git a/classes/types/deck_type.gd b/classes/types/deck_type.gd index 0772628..a26523e 100644 --- a/classes/types/deck_type.gd +++ b/classes/types/deck_type.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) class_name DeckType enum Types{ diff --git a/dist/logo-flattened.svg b/dist/logo-flattened.svg new file mode 100644 index 0000000..1c748d6 --- /dev/null +++ b/dist/logo-flattened.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dist/logo-flattened.svg.import b/dist/logo-flattened.svg.import new file mode 100644 index 0000000..0498e84 --- /dev/null +++ b/dist/logo-flattened.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bsac4pnv4a3wv" +path="res://.godot/imported/logo-flattened.svg-0ce0b3a9eae5db0ad39f3f1f09da3354.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://dist/logo-flattened.svg" +dest_files=["res://.godot/imported/logo-flattened.svg-0ce0b3a9eae5db0ad39f3f1f09da3354.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/dist/logo.svg b/dist/logo.svg new file mode 100644 index 0000000..e19865b --- /dev/null +++ b/dist/logo.svg @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dist/logo.svg.import b/dist/logo.svg.import new file mode 100644 index 0000000..ff31088 --- /dev/null +++ b/dist/logo.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bt2gnexdatmuw" +path="res://.godot/imported/logo.svg-3b682001f45a5b402a88618d2eaf767b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://dist/logo.svg" +dest_files=["res://.godot/imported/logo.svg-3b682001f45a5b402a88618d2eaf767b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..0b5066e --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,102 @@ +[preset.0] + +name="Linux/X11" +platform="Linux/X11" +runnable=true +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="dist/0.0.1/linux/StreamGraph.x86_64" +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=false +texture_format/bptc=true +texture_format/s3tc=true +texture_format/etc=false +texture_format/etc2=false +binary_format/architecture="x86_64" +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="#!/usr/bin/env bash +export DISPLAY=:0 +unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\" +\"{temp_dir}/{exe_name}\" {cmd_args}" +ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash +kill $(pgrep -x -f \"{temp_dir}/{exe_name} {cmd_args}\") +rm -rf \"{temp_dir}\"" + +[preset.1] + +name="Windows Desktop" +platform="Windows Desktop" +runnable=true +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="dist/0.0.1/windows/StreamGraph.exe" +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=false +texture_format/bptc=true +texture_format/s3tc=true +texture_format/etc=false +texture_format/etc2=false +binary_format/architecture="x86_64" +codesign/enable=false +codesign/timestamp=true +codesign/timestamp_server_url="" +codesign/digest_algorithm=1 +codesign/description="" +codesign/custom_options=PackedStringArray() +application/modify_resources=true +application/icon="" +application/console_wrapper_icon="" +application/icon_interpolation=4 +application/file_version="" +application/product_version="" +application/company_name="Eroax & Yagich" +application/product_name="StreamGraph" +application/file_description="" +application/copyright="" +application/trademarks="" +application/export_angle=0 +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}' +$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}' +$trigger = New-ScheduledTaskTrigger -Once -At 00:00 +$settings = New-ScheduledTaskSettingsSet +$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings +Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true +Start-ScheduledTask -TaskName godot_remote_debug +while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 } +Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue" +ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue +Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue +Remove-Item -Recurse -Force '{temp_dir}'" diff --git a/graph_node_renderer/about_dialog.gd b/graph_node_renderer/about_dialog.gd new file mode 100644 index 0000000..8b6ff59 --- /dev/null +++ b/graph_node_renderer/about_dialog.gd @@ -0,0 +1,11 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends AcceptDialog +class_name AboutDialog + +@onready var version_label: Label = %VersionLabel + +func _ready() -> void: + var version: String = ProjectSettings.get_setting("application/config/version") + version_label.text = "v%s" % version diff --git a/graph_node_renderer/about_dialog.tscn b/graph_node_renderer/about_dialog.tscn new file mode 100644 index 0000000..c7f9a83 --- /dev/null +++ b/graph_node_renderer/about_dialog.tscn @@ -0,0 +1,766 @@ +[gd_scene load_steps=5 format=3 uid="uid://bu466w2w3q08c"] + +[ext_resource type="Script" path="res://graph_node_renderer/about_dialog.gd" id="1_xn0s3"] +[ext_resource type="Texture2D" uid="uid://bsac4pnv4a3wv" path="res://dist/logo-flattened.svg" id="2_e1chc"] + +[sub_resource type="SystemFont" id="SystemFont_ueb25"] +font_names = PackedStringArray("Monospace") +subpixel_positioning = 0 + +[sub_resource type="LabelSettings" id="LabelSettings_qamh4"] +font = SubResource("SystemFont_ueb25") +font_size = 12 + +[node name="AboutDialog" type="AcceptDialog"] +title = "About StreamGraph" +initial_position = 2 +size = Vector2i(714, 466) +script = ExtResource("1_xn0s3") + +[node name="HBoxContainer" type="VBoxContainer" parent="."] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 706.0 +offset_bottom = 417.0 +theme_override_constants/separation = 15 + +[node name="VBoxContainer" type="HBoxContainer" parent="HBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 40 + +[node name="HBoxContainer" type="HBoxContainer" parent="HBoxContainer/VBoxContainer"] +layout_mode = 2 + +[node name="TextureRect" type="TextureRect" parent="HBoxContainer/VBoxContainer/HBoxContainer"] +layout_mode = 2 +texture = ExtResource("2_e1chc") +expand_mode = 3 + +[node name="Label" type="Label" parent="HBoxContainer/VBoxContainer/HBoxContainer"] +custom_minimum_size = Vector2(400, 0) +layout_mode = 2 +text = "StreamGraph is a node graph-based virtual stream deck and livestream automation tool." +horizontal_alignment = 1 +autowrap_mode = 3 + +[node name="VBoxContainer" type="VBoxContainer" parent="HBoxContainer/VBoxContainer"] +layout_mode = 2 + +[node name="VersionLabel" type="Label" parent="HBoxContainer/VBoxContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "v" +horizontal_alignment = 1 + +[node name="CopyrightLabel" type="Label" parent="HBoxContainer/VBoxContainer/VBoxContainer"] +layout_mode = 2 +text = "ⓒ 2023-present Eroax +ⓒ 2023-present Yagich" + +[node name="Label" type="Label" parent="HBoxContainer"] +layout_mode = 2 +text = "For third-party licenses, see the THIRDPARTY.md file." +horizontal_alignment = 1 + +[node name="Label2" type="Label" parent="HBoxContainer"] +layout_mode = 2 +text = "StreamGraph license" +horizontal_alignment = 1 + +[node name="PanelContainer" type="PanelContainer" parent="HBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ScrollContainer" type="ScrollContainer" parent="HBoxContainer/PanelContainer"] +custom_minimum_size = Vector2(0, 200) +layout_mode = 2 +horizontal_scroll_mode = 0 + +[node name="MarginContainer" type="MarginContainer" parent="HBoxContainer/PanelContainer/ScrollContainer"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/margin_left = 4 +theme_override_constants/margin_top = 4 +theme_override_constants/margin_right = 4 +theme_override_constants/margin_bottom = 4 + +[node name="LicenseLabel" type="Label" parent="HBoxContainer/PanelContainer/ScrollContainer/MarginContainer"] +custom_minimum_size = Vector2(600, 0) +layout_mode = 2 +size_flags_horizontal = 3 +text = " GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + \"This License\" refers to version 3 of the GNU General Public License. + + \"Copyright\" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + \"The Program\" refers to any copyrightable work licensed under this +License. Each licensee is addressed as \"you\". \"Licensees\" and +\"recipients\" may be individuals or organizations. + + To \"modify\" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a \"modified version\" of the +earlier work or a work \"based on\" the earlier work. + + A \"covered work\" means either the unmodified Program or a work based +on the Program. + + To \"propagate\" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To \"convey\" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays \"Appropriate Legal Notices\" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The \"source code\" for a work means the preferred form of the work +for making modifications to it. \"Object code\" means any non-source +form of a work. + + A \"Standard Interface\" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The \"System Libraries\" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +\"Major Component\", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The \"Corresponding Source\" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + \"keep intact all notices\". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +\"aggregate\" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A \"User Product\" is either (1) a \"consumer product\", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, \"normally used\" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + \"Installation Information\" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + \"Additional permissions\" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered \"further +restrictions\" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An \"entity transaction\" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A \"contributor\" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's \"contributor version\". + + A contributor's \"essential patent claims\" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, \"control\" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a \"patent license\" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To \"grant\" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. \"Knowingly relying\" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is \"discriminatory\" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License \"or any later version\" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the \"copyright\" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an \"about box\". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a \"copyright disclaimer\" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +." +label_settings = SubResource("LabelSettings_qamh4") +autowrap_mode = 3 diff --git a/graph_node_renderer/add_node_menu.gd b/graph_node_renderer/add_node_menu.gd index c38a5dd..395c1ec 100644 --- a/graph_node_renderer/add_node_menu.gd +++ b/graph_node_renderer/add_node_menu.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends MarginContainer class_name AddNodeMenu @@ -83,6 +86,7 @@ func search(text: String) -> void: ## Callback for [member search_line_edit]'s input events. Handles highlighting items when navigating with up/down arrow keys. func _on_search_line_edit_gui_input(event: InputEvent) -> void: if event.is_action_pressed("ui_down"): + search_line_edit.accept_event() var category: Category for i: String in categories: var c: Category = categories[i] @@ -103,6 +107,7 @@ func _on_search_line_edit_gui_input(event: InputEvent) -> void: scroll_container.ensure_control_visible(category.get_child(item + 1)) if event.is_action_pressed("ui_up"): + search_line_edit.accept_event() var category: Category for i: String in categories: var c: Category = categories[i] @@ -178,7 +183,6 @@ func _on_category_collapse_toggled(collapsed: bool, category: String) -> void: class Category extends VBoxContainer: const COLLAPSE_ICON := preload("res://graph_node_renderer/textures/collapse-icon.svg") const COLLAPSE_ICON_COLLAPSED := preload("res://graph_node_renderer/textures/collapse-icon-collapsed.svg") - var collapse_button: Button ## Emitted when a child item has been pressed. diff --git a/graph_node_renderer/debug_decks_list.gd b/graph_node_renderer/debug_decks_list.gd new file mode 100644 index 0000000..edb876f --- /dev/null +++ b/graph_node_renderer/debug_decks_list.gd @@ -0,0 +1,29 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends VBoxContainer +class_name DebugDecksList + +signal item_pressed(deck_id: String, instance_id: String) + + +func _ready() -> void: + for i in get_children(): + i.queue_free() + + for deck_id: String in DeckHolder.decks: + if DeckHolder.decks[deck_id] is Deck: + var b := Button.new() + b.text = deck_id + b.pressed.connect(func(): + item_pressed.emit(deck_id, "") + ) + add_child(b) + else: + for instance_id: String in DeckHolder.decks[deck_id]: + var b := Button.new() + b.text = "%s::%s" % [deck_id, instance_id] + b.pressed.connect(func(): + item_pressed.emit(deck_id, instance_id) + ) + add_child(b) diff --git a/graph_node_renderer/debug_decks_list.tscn b/graph_node_renderer/debug_decks_list.tscn new file mode 100644 index 0000000..ef38b89 --- /dev/null +++ b/graph_node_renderer/debug_decks_list.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://dm7sc6364j84i"] + +[ext_resource type="Script" path="res://graph_node_renderer/debug_decks_list.gd" id="1_etp6v"] + +[node name="DebugDecksList" type="VBoxContainer"] +script = ExtResource("1_etp6v") diff --git a/graph_node_renderer/deck_holder_renderer.gd b/graph_node_renderer/deck_holder_renderer.gd index 865eec2..39e14e5 100644 --- a/graph_node_renderer/deck_holder_renderer.gd +++ b/graph_node_renderer/deck_holder_renderer.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends Control class_name DeckHolderRenderer @@ -5,14 +8,23 @@ class_name DeckHolderRenderer ## ## Entry point for the [GraphEdit] based Renderer -## Reference to the base scene for [DeckRendererGraphEdit] +## Reference to the base scene for [DeckRessssssndererGraphEdit] const DECK_SCENE := preload("res://graph_node_renderer/deck_renderer_graph_edit.tscn") +const DEBUG_DECKS_LIST := preload("res://graph_node_renderer/debug_decks_list.tscn") + +const PERSISTENCE_NAMESPACE := "default" ## Reference to the main windows [TabContainerCustom] -@onready var tab_container: TabContainerCustom = %TabContainerCustom as TabContainerCustom +@onready var tab_container := %TabContainerCustom as TabContainerCustom ## Reference to the [FileDialog] used for File operations through the program. @onready var file_dialog: FileDialog = $FileDialog +@export var new_deck_shortcut: Shortcut +@export var open_deck_shortcut: Shortcut +@export var save_deck_shortcut: Shortcut +@export var save_deck_as_shortcut: Shortcut +@export var close_deck_shortcut: Shortcut + ## Enum for storing the Options in the "File" PopupMenu. enum FileMenuId { NEW, @@ -20,22 +32,94 @@ enum FileMenuId { SAVE = 3, SAVE_AS, CLOSE = 6, + RECENTS, } +@onready var file_popup_menu: PopupMenu = %File as PopupMenu +var max_recents := 4 +var recent_files := [] +var recent_path: String +@onready var unsaved_changes_dialog_single_deck := $UnsavedChangesDialogSingleDeck as UnsavedChangesDialogSingleDeck +@onready var unsaved_changes_dialog: ConfirmationDialog = $UnsavedChangesDialog + +enum ConnectionsMenuId { + OBS, + TWITCH, +} + +enum DebugMenuId { + DECKS, + EMBED_SUBWINDOWS, +} +@onready var debug_popup_menu: PopupMenu = %Debug + +enum HelpMenuId { + DOCS, + ABOUT, +} +@onready var about_dialog: AcceptDialog = %AboutDialog ## Weak Reference to the Deck that is currently going to be saved. var _deck_to_save: WeakRef +@onready var no_obsws := %NoOBSWS as NoOBSWS + +@onready var obs_setup_dialog := $OBSWebsocketSetupDialog as OBSWebsocketSetupDialog +@onready var twitch_setup_dialog := $Twitch_Setup_Dialog as TwitchSetupDialog +@onready var logger_renderer: LoggerRenderer = %LoggerRenderer + func _ready() -> void: + get_tree().auto_accept_quit = false tab_container.add_button_pressed.connect(add_empty_deck) + tab_container.tab_changed.connect(_on_tab_container_tab_changed) + RendererPersistence.init_namespace(PERSISTENCE_NAMESPACE) + + var embed_subwindows: bool = RendererPersistence.get_or_create(PERSISTENCE_NAMESPACE, "config", "embed_subwindows", true) + debug_popup_menu.set_item_checked( + DebugMenuId.EMBED_SUBWINDOWS, + embed_subwindows + ) + + get_tree().get_root().gui_embed_subwindows = embed_subwindows + file_dialog.use_native_dialog = !embed_subwindows + + recent_files = RendererPersistence.get_or_create( + PERSISTENCE_NAMESPACE, "config", + "recent_files", [] + ) + recent_path = RendererPersistence.get_or_create( + PERSISTENCE_NAMESPACE, "config", + "recent_path", OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS) + ) + add_recents_to_menu() tab_container.tab_close_requested.connect( func(tab: int): - DeckHolder.close_deck(tab_container.get_tab_metadata(tab)) - tab_container.close_tab(tab) + if tab_container.get_tab_metadata(tab, "dirty") && !tab_container.get_tab_metadata(tab, "group"): + unsaved_changes_dialog_single_deck.set_meta("tab", tab) + unsaved_changes_dialog_single_deck.show() + return + close_tab(tab) ) file_dialog.canceled.connect(disconnect_file_dialog_signals) + Connections.obs_websocket = no_obsws + Connections.twitch = %Twitch_Connection + + Connections.twitch.chat_socket.chat_received_rich.connect(Connections._twitch_chat_received) + + file_popup_menu.set_item_shortcut(FileMenuId.NEW, new_deck_shortcut) + file_popup_menu.set_item_shortcut(FileMenuId.OPEN, open_deck_shortcut) + file_popup_menu.set_item_shortcut(FileMenuId.SAVE, save_deck_shortcut) + file_popup_menu.set_item_shortcut(FileMenuId.SAVE_AS, save_deck_as_shortcut) + file_popup_menu.set_item_shortcut(FileMenuId.CLOSE, close_deck_shortcut) + + +func _on_tab_container_tab_changed(tab: int) -> void: + var is_group = tab_container.get_tab_metadata(tab, "group", false) + file_popup_menu.set_item_disabled(FileMenuId.SAVE, is_group) + file_popup_menu.set_item_disabled(FileMenuId.SAVE_AS, is_group) + ## Called when the File button in the [MenuBar] is pressed with the [param id] ## of the button within it that was pressed. @@ -44,27 +128,46 @@ func _on_file_id_pressed(id: int) -> void: FileMenuId.NEW: add_empty_deck() FileMenuId.OPEN: - open_open_dialog("res://") - FileMenuId.SAVE: + open_open_dialog(recent_path) + FileMenuId.SAVE when tab_container.get_tab_count() > 0: save_active_deck() - FileMenuId.SAVE_AS: - open_save_dialog("res://") + FileMenuId.SAVE_AS when tab_container.get_tab_count() > 0: + open_save_dialog(tab_container.get_tab_metadata(tab_container.get_current_tab(), "path")) FileMenuId.CLOSE: close_current_tab() + _ when id in range(FileMenuId.RECENTS, FileMenuId.RECENTS + max_recents + 1): + open_deck_at_path(recent_files[id - FileMenuId.RECENTS - 1]) ## Adds an empty [DeckRendererGraphEdit] with a corresponding [Deck] for it's data. func add_empty_deck() -> void: var deck := DeckHolder.add_empty_deck() var inst: DeckRendererGraphEdit = DECK_SCENE.instantiate() inst.deck = deck - tab_container.add_content(inst, "Deck %s" % (tab_container.get_tab_count() + 1)) - tab_container.set_tab_metadata(tab_container.get_current_tab(), deck) - inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested.bind(deck)) + var tab := tab_container.add_content(inst, "") + tab_container.set_tab_metadata(tab, "id", deck.id) + tab_container.set_tab_metadata(tab, "group", false) + tab_container.set_tab_metadata(tab, "path", recent_path.path_join("")) + inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) + inst.dirty_state_changed.connect(_on_deck_renderer_dirty_state_changed.bind(inst)) + tab_container.set_current_tab(tab) ## Closes the current tab in [member tab_container] func close_current_tab() -> void: tab_container.close_tab(tab_container.get_current_tab()) + +func close_tab(tab: int) -> void: + if !tab_container.get_tab_metadata(tab, "group"): + var groups := DeckHolder.close_deck(tab_container.get_tab_metadata(tab, "id")) + # close tabs associated with this deck's groups + for group in groups: + for c_tab in range(tab_container.get_tab_count() - 1, 0, -1): + if tab_container.get_tab_metadata(c_tab, "id") == group: + tab_container.close_tab(tab) + await get_tree().process_frame + + tab_container.close_tab(tab) + ## Opens [member file_dialog] with the mode [member FileDialog.FILE_MODE_SAVE_FILE] ## as well as getting a weakref to the active [Deck] func open_save_dialog(path: String) -> void: @@ -80,7 +183,7 @@ func open_save_dialog(path: String) -> void: func open_open_dialog(path: String) -> void: file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILES file_dialog.title = "Open Deck(s)" - file_dialog.current_path = path + file_dialog.current_path = path + "/" file_dialog.popup_centered() file_dialog.files_selected.connect(_on_file_dialog_open_files, CONNECT_ONE_SHOT) @@ -92,20 +195,46 @@ func _on_file_dialog_save_file(path: String) -> void: return deck.save_path = path + tab_container.set_tab_title(tab_container.get_current_tab(), path.get_file()) + var renderer: DeckRendererGraphEdit = tab_container.get_content(tab_container.get_current_tab()) as DeckRendererGraphEdit + renderer.dirty = false + # TODO: put this into DeckHolder instead var json := JSON.stringify(deck.to_dict(), "\t") var f := FileAccess.open(path, FileAccess.WRITE) f.store_string(json) + add_recent_file(get_active_deck().save_path) + recent_path = path.get_base_dir() + RendererPersistence.set_value(PERSISTENCE_NAMESPACE, "config", "recent_path", recent_path) + ## Connected to [signal FileDialog.open_files] on [member file_dialog]. Opens ## the selected paths, instantiating [DeckRenderGraphEdit]s and [Deck]s for each. func _on_file_dialog_open_files(paths: PackedStringArray) -> void: for path in paths: - var deck := DeckHolder.open_deck_from_file(path) - var inst: DeckRendererGraphEdit = DECK_SCENE.instantiate() - inst.deck = deck - tab_container.add_content(inst, "Deck %s" % (tab_container.get_tab_count() + 1)) - inst.initialize_from_deck() - inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested.bind(deck)) + open_deck_at_path(path) + + +func open_deck_at_path(path: String) -> void: + for tab in tab_container.get_tab_count(): + if tab_container.get_tab_metadata(tab, "path") == path: + tab_container.set_current_tab(tab) + return + + var deck := DeckHolder.open_deck_from_file(path) + var inst: DeckRendererGraphEdit = DECK_SCENE.instantiate() + inst.deck = deck + var tab := tab_container.add_content(inst, path.get_file()) + tab_container.set_tab_metadata(tab, "id", deck.id) + tab_container.set_tab_metadata(tab, "group", false) + tab_container.set_tab_metadata(tab, "path", path) + inst.initialize_from_deck() + inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) + inst.dirty_state_changed.connect(_on_deck_renderer_dirty_state_changed.bind(inst)) + add_recent_file(path) + recent_path = path.get_base_dir() + RendererPersistence.set_value(PERSISTENCE_NAMESPACE, "config", "recent_path", recent_path) + tab_container.set_current_tab(tab) + ## Gets the currently active [Deck] from [member tab_container] func get_active_deck() -> Deck: @@ -116,12 +245,24 @@ func get_active_deck() -> Deck: ## Saves the active [Deck] in [member tab_container] func save_active_deck() -> void: - if get_active_deck().save_path.is_empty(): + save_tab(tab_container.get_current_tab()) + + +func save_tab(tab: int) -> void: + if tab_container.get_tab_metadata(tab, "group"): + return + + var renderer := tab_container.get_content(tab) as DeckRendererGraphEdit + var deck := renderer.deck + if deck.save_path.is_empty(): open_save_dialog("res://") else: - var json := JSON.stringify(get_active_deck().to_dict(), "\t") - var f := FileAccess.open(get_active_deck().save_path, FileAccess.WRITE) + var json := JSON.stringify(deck.to_dict(), "\t") + var f := FileAccess.open(deck.save_path, FileAccess.WRITE) f.store_string(json) + add_recent_file(deck.save_path) + renderer.dirty = false + ## Disconnects the [FileDialog] signals if they are already connected. func disconnect_file_dialog_signals() -> void: @@ -134,11 +275,183 @@ func disconnect_file_dialog_signals() -> void: ## Connected to [signal DeckRenderGraphEdit.group_entered_request] to allow entering ## groups based off the given [param group_id] and [param deck]. As well as adding ## a corresponding tab to [member tab_container] -func _on_deck_renderer_group_enter_requested(group_id: String, deck: Deck) -> void: - var group_deck := deck.get_group(group_id) +func _on_deck_renderer_group_enter_requested(group_id: String) -> void: + #var group_deck := deck.get_group(group_id) + for tab in tab_container.get_tab_count(): + if tab_container.get_tab_metadata(tab, "id") == group_id: + tab_container.set_current_tab(tab) + return + var group_deck := DeckHolder.get_deck(group_id) var deck_renderer: DeckRendererGraphEdit = DECK_SCENE.instantiate() deck_renderer.deck = group_deck deck_renderer.initialize_from_deck() - tab_container.add_content(deck_renderer, "Group %s" % (tab_container.get_tab_count() + 1)) - tab_container.set_tab_metadata(tab_container.get_current_tab(), group_deck) - deck_renderer.group_enter_requested.connect(_on_deck_renderer_group_enter_requested.bind(deck_renderer.deck)) + var tab := tab_container.add_content(deck_renderer, "(g) %s" % group_id.left(8)) + tab_container.set_tab_metadata(tab, "id", group_id) + tab_container.set_tab_metadata(tab, "group", true) + deck_renderer.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) + tab_container.set_current_tab(tab) + + +func _on_connections_id_pressed(id: int) -> void: + match id: + ConnectionsMenuId.OBS: + obs_setup_dialog.popup_centered() + ConnectionsMenuId.TWITCH: + twitch_setup_dialog.popup_centered() + + +func _on_obs_websocket_setup_dialog_connect_button_pressed(state: OBSWebsocketSetupDialog.ConnectionState) -> void: + match state: + OBSWebsocketSetupDialog.ConnectionState.DISCONNECTED: + obs_setup_dialog.set_button_state(OBSWebsocketSetupDialog.ConnectionState.CONNECTING) + no_obsws.connect_to_obsws(obs_setup_dialog.get_port(), obs_setup_dialog.get_password()) + await no_obsws.connection_ready + obs_setup_dialog.set_button_state(OBSWebsocketSetupDialog.ConnectionState.CONNECTED) + + +func _process(delta: float) -> void: + DeckHolder.send_event(&"process", {"delta": delta}) + + +func _on_debug_id_pressed(id: int) -> void: + match id: + DebugMenuId.DECKS: + var d := AcceptDialog.new() + var debug_decks: DebugDecksList = DEBUG_DECKS_LIST.instantiate() + d.add_child(debug_decks) + d.canceled.connect(d.queue_free) + d.confirmed.connect(d.queue_free) + debug_decks.item_pressed.connect(_on_debug_decks_viewer_item_pressed) + add_child(d) + d.popup_centered() + DebugMenuId.EMBED_SUBWINDOWS: + var c := debug_popup_menu.is_item_checked(id) + debug_popup_menu.set_item_checked(id, !c) + get_tree().get_root().gui_embed_subwindows = !c + RendererPersistence.set_value(PERSISTENCE_NAMESPACE, "config", "embed_subwindows", !c) + file_dialog.use_native_dialog = c + + +func _on_debug_decks_viewer_item_pressed(deck_id: String, instance_id: String) -> void: + if instance_id == "": + var deck := DeckHolder.get_deck(deck_id) + var inst: DeckRendererGraphEdit = DECK_SCENE.instantiate() + inst.deck = deck + var tab := tab_container.add_content(inst, "" % [deck_id.left(8)]) + tab_container.set_tab_metadata(tab, "id", deck.id) + inst.initialize_from_deck() + inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) + tab_container.set_current_tab(tab) + else: + var deck := DeckHolder.get_group_instance(deck_id, instance_id) + var inst: DeckRendererGraphEdit = DECK_SCENE.instantiate() + inst.deck = deck + var tab := tab_container.add_content(inst, "" % [deck_id.left(8), instance_id.left(8)]) + tab_container.set_tab_metadata(tab, "id", deck.id) + inst.initialize_from_deck() + inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) + tab_container.set_current_tab(tab) + + +func _on_deck_renderer_dirty_state_changed(renderer: DeckRendererGraphEdit) -> void: + var idx: int = range(tab_container.get_tab_count()).filter( + func(x: int): + return tab_container.get_content(x) == renderer + )[0] + var title := tab_container.get_tab_title(idx).trim_suffix("(*)") + if renderer.dirty: + tab_container.set_tab_title(idx, "%s(*)" % title) + else: + tab_container.set_tab_title(idx, title.trim_suffix("(*)")) + tab_container.set_tab_metadata(idx, "dirty", renderer.dirty) + + +func add_recent_file(path: String) -> void: + var item := recent_files.find(path) + if item == -1: + recent_files.push_front(path) + else: + recent_files.push_front(recent_files.pop_at(item)) + recent_files = recent_files.slice(0, max_recents) + + add_recents_to_menu() + + +func add_recents_to_menu() -> void: + if recent_files.is_empty(): + return + + if file_popup_menu.get_item_count() > FileMenuId.RECENTS + 1: + var end := file_popup_menu.get_item_count() - FileMenuId.RECENTS - 1 + for i in end: + file_popup_menu.remove_item(file_popup_menu.get_item_count() - 1) + + var reduce_length := recent_files.any(func(x: String): return x.length() > 35) + + for i in recent_files.size(): + var file = recent_files[i] as String + if reduce_length: + # shorten the basepath to be the first letter of all folders + var base: String = Array( + file.get_base_dir().split("/", false) + ).reduce( + func(a: String, s: String): + return a + s[0] + "/", + "/") + var filename := file.get_file() + file = base.path_join(filename) + + file_popup_menu.add_item(file) + var s := Shortcut.new() + var k := InputEventKey.new() + k.keycode = KEY_1 + i + k.ctrl_pressed = true + s.events.append(k) + file_popup_menu.set_item_shortcut(file_popup_menu.get_item_count() - 1, s) + + RendererPersistence.set_value(PERSISTENCE_NAMESPACE, "config", "recent_files", recent_files) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_WM_CLOSE_REQUEST: + RendererPersistence.commit(PERSISTENCE_NAMESPACE) + + if range(tab_container.get_tab_count()).any(func(x: int): return tab_container.get_content(x).dirty): + unsaved_changes_dialog.show() + else: + for i in tab_container.get_tab_count(): + close_tab(i) + + get_tree().quit() + + +func _on_unsaved_changes_dialog_single_deck_confirmed() -> void: + save_tab(unsaved_changes_dialog_single_deck.get_meta("tab")) + close_tab(unsaved_changes_dialog_single_deck.get_meta("tab")) + + +func _on_unsaved_changes_dialog_single_deck_custom_action(action: StringName) -> void: + if action == &"force_close": + close_tab(unsaved_changes_dialog_single_deck.get_meta("tab")) + unsaved_changes_dialog_single_deck.hide() + + +func _on_unsaved_changes_dialog_confirmed() -> void: + for i in tab_container.get_tab_count(): + close_tab(i) + + get_tree().quit() + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("toggle_console"): + logger_renderer.visible = !logger_renderer.visible + accept_event() + + +func _on_help_id_pressed(id: int) -> void: + match id: + HelpMenuId.ABOUT: + about_dialog.show() + HelpMenuId.DOCS: + OS.shell_open("https://codeberg.org/Eroax/StreamGraph/wiki") diff --git a/graph_node_renderer/deck_holder_renderer.tscn b/graph_node_renderer/deck_holder_renderer.tscn index fa41b7e..d2b2864 100644 --- a/graph_node_renderer/deck_holder_renderer.tscn +++ b/graph_node_renderer/deck_holder_renderer.tscn @@ -1,8 +1,62 @@ -[gd_scene load_steps=4 format=3 uid="uid://duaah5x0jhkn6"] +[gd_scene load_steps=22 format=3 uid="uid://duaah5x0jhkn6"] [ext_resource type="Script" path="res://graph_node_renderer/deck_holder_renderer.gd" id="1_67g2g"] [ext_resource type="PackedScene" uid="uid://b84f2ngtcm5b8" path="res://graph_node_renderer/tab_container_custom.tscn" id="1_s3ug2"] [ext_resource type="Theme" uid="uid://dqqdqscid2iem" path="res://graph_node_renderer/default_theme.tres" id="1_tgul2"] +[ext_resource type="Script" path="res://addons/no-obs-ws/NoOBSWS.gd" id="4_nu72u"] +[ext_resource type="PackedScene" uid="uid://duvh3r740w2p5" path="res://graph_node_renderer/logger_renderer.tscn" id="4_pvexk"] +[ext_resource type="Script" path="res://addons/no_twitch/twitch_connection.gd" id="5_3n36q"] +[ext_resource type="PackedScene" uid="uid://eioso6jb42jy" path="res://graph_node_renderer/obs_websocket_setup_dialog.tscn" id="5_uo2gj"] +[ext_resource type="PackedScene" uid="uid://bq2lxmbnic4lc" path="res://graph_node_renderer/twitch_setup_dialog.tscn" id="7_7rhap"] +[ext_resource type="PackedScene" uid="uid://cuwou2aa7qfc2" path="res://graph_node_renderer/unsaved_changes_dialog_single_deck.tscn" id="8_qf6ve"] +[ext_resource type="PackedScene" uid="uid://cvvkj138fg8jg" path="res://graph_node_renderer/unsaved_changes_dialog.tscn" id="9_4n0q6"] +[ext_resource type="PackedScene" uid="uid://bu466w2w3q08c" path="res://graph_node_renderer/about_dialog.tscn" id="11_6ln7n"] + +[sub_resource type="InputEventKey" id="InputEventKey_giamc"] +device = -1 +ctrl_pressed = true +keycode = 78 +unicode = 110 + +[sub_resource type="Shortcut" id="Shortcut_30rq6"] +events = [SubResource("InputEventKey_giamc")] + +[sub_resource type="InputEventKey" id="InputEventKey_cyjf4"] +device = -1 +ctrl_pressed = true +keycode = 79 +unicode = 111 + +[sub_resource type="Shortcut" id="Shortcut_m48tj"] +events = [SubResource("InputEventKey_cyjf4")] + +[sub_resource type="InputEventKey" id="InputEventKey_jgr3p"] +device = -1 +ctrl_pressed = true +keycode = 83 +unicode = 115 + +[sub_resource type="Shortcut" id="Shortcut_xr6s4"] +events = [SubResource("InputEventKey_jgr3p")] + +[sub_resource type="InputEventKey" id="InputEventKey_762xj"] +device = -1 +shift_pressed = true +ctrl_pressed = true +keycode = 83 +unicode = 83 + +[sub_resource type="Shortcut" id="Shortcut_myxuq"] +events = [SubResource("InputEventKey_762xj")] + +[sub_resource type="InputEventKey" id="InputEventKey_exx3o"] +device = -1 +ctrl_pressed = true +keycode = 87 +unicode = 119 + +[sub_resource type="Shortcut" id="Shortcut_46v8y"] +events = [SubResource("InputEventKey_exx3o")] [node name="DeckHolderRenderer" type="Control"] layout_mode = 3 @@ -13,6 +67,11 @@ grow_horizontal = 2 grow_vertical = 2 theme = ExtResource("1_tgul2") script = ExtResource("1_67g2g") +new_deck_shortcut = SubResource("Shortcut_30rq6") +open_deck_shortcut = SubResource("Shortcut_m48tj") +save_deck_shortcut = SubResource("Shortcut_xr6s4") +save_deck_as_shortcut = SubResource("Shortcut_myxuq") +close_deck_shortcut = SubResource("Shortcut_46v8y") [node name="MarginContainer" type="MarginContainer" parent="."] layout_mode = 1 @@ -28,7 +87,7 @@ theme_override_constants/margin_bottom = 2 [node name="VSplitContainer" type="VSplitContainer" parent="MarginContainer"] layout_mode = 2 -split_offset = 677 +split_offset = 460 [node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VSplitContainer"] layout_mode = 2 @@ -37,7 +96,8 @@ layout_mode = 2 layout_mode = 2 [node name="File" type="PopupMenu" parent="MarginContainer/VSplitContainer/VBoxContainer/MenuBar"] -item_count = 7 +unique_name_in_owner = true +item_count = 8 item_0/text = "New Deck" item_0/id = 0 item_1/text = "Open Deck" @@ -54,20 +114,81 @@ item_5/id = 5 item_5/separator = true item_6/text = "Close Deck" item_6/id = 6 +item_7/text = "Recent Decks" +item_7/id = 7 +item_7/separator = true [node name="Edit" type="PopupMenu" parent="MarginContainer/VSplitContainer/VBoxContainer/MenuBar"] +unique_name_in_owner = true + +[node name="Connections" type="PopupMenu" parent="MarginContainer/VSplitContainer/VBoxContainer/MenuBar"] +unique_name_in_owner = true +item_count = 2 +item_0/text = "OBS..." +item_0/id = 0 +item_1/text = "Twitch.." +item_1/id = 1 + +[node name="Debug" type="PopupMenu" parent="MarginContainer/VSplitContainer/VBoxContainer/MenuBar"] +unique_name_in_owner = true +item_count = 2 +item_0/text = "Decks..." +item_0/id = 0 +item_1/text = "Embed subwindows" +item_1/checkable = 1 +item_1/checked = true +item_1/id = 1 + +[node name="Help" type="PopupMenu" parent="MarginContainer/VSplitContainer/VBoxContainer/MenuBar"] +unique_name_in_owner = true +item_count = 2 +item_0/text = "Online Documentation" +item_0/id = 0 +item_1/text = "About..." +item_1/id = 1 [node name="TabContainerCustom" parent="MarginContainer/VSplitContainer/VBoxContainer" instance=ExtResource("1_s3ug2")] unique_name_in_owner = true layout_mode = 2 -[node name="ConsoleContainer" type="PanelContainer" parent="MarginContainer/VSplitContainer"] +[node name="LoggerRenderer" parent="MarginContainer/VSplitContainer" instance=ExtResource("4_pvexk")] +unique_name_in_owner = true +visible = false layout_mode = 2 [node name="FileDialog" type="FileDialog" parent="."] size = Vector2i(776, 447) mode_overrides_title = false access = 2 +filters = PackedStringArray("*.deck;StreamGraph Decks") use_native_dialog = true +[node name="Connections" type="Node" parent="."] + +[node name="NoOBSWS" type="Node" parent="Connections"] +unique_name_in_owner = true +script = ExtResource("4_nu72u") + +[node name="Twitch_Connection" type="Node" parent="Connections"] +unique_name_in_owner = true +script = ExtResource("5_3n36q") + +[node name="OBSWebsocketSetupDialog" parent="." instance=ExtResource("5_uo2gj")] + +[node name="Twitch_Setup_Dialog" parent="." instance=ExtResource("7_7rhap")] + +[node name="UnsavedChangesDialogSingleDeck" parent="." instance=ExtResource("8_qf6ve")] + +[node name="UnsavedChangesDialog" parent="." instance=ExtResource("9_4n0q6")] + +[node name="AboutDialog" parent="." instance=ExtResource("11_6ln7n")] +unique_name_in_owner = true + [connection signal="id_pressed" from="MarginContainer/VSplitContainer/VBoxContainer/MenuBar/File" to="." method="_on_file_id_pressed"] +[connection signal="id_pressed" from="MarginContainer/VSplitContainer/VBoxContainer/MenuBar/Connections" to="." method="_on_connections_id_pressed"] +[connection signal="id_pressed" from="MarginContainer/VSplitContainer/VBoxContainer/MenuBar/Debug" to="." method="_on_debug_id_pressed"] +[connection signal="id_pressed" from="MarginContainer/VSplitContainer/VBoxContainer/MenuBar/Help" to="." method="_on_help_id_pressed"] +[connection signal="connect_button_pressed" from="OBSWebsocketSetupDialog" to="." method="_on_obs_websocket_setup_dialog_connect_button_pressed"] +[connection signal="confirmed" from="UnsavedChangesDialogSingleDeck" to="." method="_on_unsaved_changes_dialog_single_deck_confirmed"] +[connection signal="custom_action" from="UnsavedChangesDialogSingleDeck" to="." method="_on_unsaved_changes_dialog_single_deck_custom_action"] +[connection signal="confirmed" from="UnsavedChangesDialog" to="." method="_on_unsaved_changes_dialog_confirmed"] diff --git a/graph_node_renderer/deck_node_renderer_graph_node.gd b/graph_node_renderer/deck_node_renderer_graph_node.gd index 57d31a4..26544ad 100644 --- a/graph_node_renderer/deck_node_renderer_graph_node.gd +++ b/graph_node_renderer/deck_node_renderer_graph_node.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends GraphNode class_name DeckNodeRendererGraphNode @@ -17,18 +20,28 @@ func _ready() -> void: node.ports_updated.connect(_on_node_ports_updated) for port in node.get_all_ports(): update_port(port) + position_offset_changed.connect(_on_position_offset_changed) + node.renamed.connect(_on_node_renamed) + if node.node_type == "group_node": + get_titlebar_hbox().tooltip_text = "Group %s" % node.group_id.left(8) ## Connected to [signal GraphElement.position_offset_updated] and updates the ## [member node]s properties func _on_position_offset_changed() -> void: node.position.x = position_offset.x node.position.y = position_offset.y + node.position_updated.emit(node.position) + (get_parent() as DeckRendererGraphEdit).dirty = true ## Connected to [member node]s [signal position_updated] to keep parity with the ## data position. func _on_node_position_updated(new_position: Dictionary) -> void: + position_offset_changed.disconnect(_on_position_offset_changed) position_offset.x = new_position.x position_offset.y = new_position.y + position_offset_changed.connect(_on_position_offset_changed) + (get_parent() as DeckRendererGraphEdit).dirty = true + ## Connected to [member node]s [signal port_added] handles setting up the specified ## [member Port.descriptor] with it's required nodes/signals etc. + adding the port @@ -64,6 +77,10 @@ func _on_node_ports_updated() -> void: update_port(port) +func _on_node_renamed(new_name: String) -> void: + title = new_name + + func update_port(port: Port) -> void: var descriptor_split := port.descriptor.split(":") match descriptor_split[0]: @@ -92,8 +109,14 @@ func update_port(port: Port) -> void: line_edit.text_changed.connect(port.set_value) "singlechoice": var box := OptionButton.new() - for item in descriptor_split.slice(1): - box.add_item(item) + if descriptor_split.slice(1).is_empty(): + if port.value: + box.add_item(port.value) + else: + box.add_item(port.label) + else: + for item in descriptor_split.slice(1): + box.add_item(item) add_child(box) port.value_callback = func(): return box.get_item_text(box.get_selected_id()) if port.type == DeckType.Types.STRING: @@ -111,10 +134,22 @@ func update_port(port: Port) -> void: code_edit.text_changed.connect(port.set_value.bind(code_edit.get_text)) code_edit.custom_minimum_size = Vector2(200, 100) code_edit.size_flags_vertical = SIZE_EXPAND_FILL + "checkbox": + var cb := CheckBox.new() + add_child(cb) + if descriptor_split.size() > 1: + cb.button_pressed = true + if port.value is bool: + cb.button_pressed = port.value + cb.text = port.label + port.value_callback = cb.is_pressed + cb.toggled.connect(port.set_value) _: var label := Label.new() add_child(label) label.text = port.label + if port.port_type == DeckNode.PortType.OUTPUT: + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT set_slot( port.index, diff --git a/graph_node_renderer/deck_node_renderer_graph_node.tscn b/graph_node_renderer/deck_node_renderer_graph_node.tscn index ab50a1f..ecd574c 100644 --- a/graph_node_renderer/deck_node_renderer_graph_node.tscn +++ b/graph_node_renderer/deck_node_renderer_graph_node.tscn @@ -9,5 +9,3 @@ offset_bottom = 55.0 resizable = true title = "Deck Node" script = ExtResource("1_pos0w") - -[connection signal="position_offset_changed" from="." to="." method="_on_position_offset_changed"] diff --git a/graph_node_renderer/deck_renderer_graph_edit.gd b/graph_node_renderer/deck_renderer_graph_edit.gd index 3cdd2e9..9d3962d 100644 --- a/graph_node_renderer/deck_renderer_graph_edit.gd +++ b/graph_node_renderer/deck_renderer_graph_edit.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends GraphEdit class_name DeckRendererGraphEdit @@ -17,6 +20,9 @@ var add_node_menu: AddNodeMenu ## nodes in [method _on_add_node_menu_node_selected] var popup_position: Vector2 +var rename_popup := RenamePopup.new() +@export var rename_popup_size: Vector2i = Vector2i(200, 0) + ## References the [Deck] that holds all the functional properties of this [DeckRendererGraphEdit] var deck: Deck: set(v): @@ -27,6 +33,17 @@ var deck: Deck: ## Emits when Group creation is requested. Ex. Hitting the "group_nodes" Hotkey. signal group_enter_requested(group_id: String) +var dirty: bool = false: + set(v): + if change_dirty: + dirty = v + dirty_state_changed.emit() +var is_group: bool = false + +var change_dirty: bool = true + +signal dirty_state_changed + ## Sets up the [member search_popup_panel] with an instance of [member ADD_NODE_SCENE] ## stored in [member add_node_menu]. And sets its size of [member search_popup_panel] to ## [member add_node_popup_size] @@ -37,6 +54,11 @@ func _ready() -> void: search_popup_panel.size = search_popup_size add_child(search_popup_panel, false, Node.INTERNAL_MODE_BACK) + rename_popup = RenamePopup.new() + rename_popup.rename_confirmed.connect(_on_rename_popup_rename_confirmed) + rename_popup.close_requested.connect(_on_rename_popup_closed) + add_child(rename_popup, false, Node.INTERNAL_MODE_FRONT) + for t: DeckType.Types in DeckType.CONVERSION_MAP: for out_type: DeckType.Types in DeckType.CONVERSION_MAP[t]: add_valid_connection_type(t, out_type) @@ -55,13 +77,14 @@ func attempt_connection(from_node_name: StringName, from_port: int, to_node_name #var from_output := from_node_renderer.node.get_global_port_idx_from_output(from_port) #var to_input := to_node_renderer.node.get_global_port_idx_from_input(to_port) - if deck.connect_nodes(from_node_renderer.node, to_node_renderer.node, from_port, to_port): + if deck.connect_nodes(from_node_renderer.node._id, to_node_renderer.node._id, from_port, to_port): connect_node( from_node_renderer.name, from_port, to_node_renderer.name, to_port ) + dirty = true ## Receives [signal GraphEdit.disconnection_request] and attempts to disconnect the two [DeckNode]s ## involved, utilizes [NodeDB] for accessing them. @@ -72,7 +95,7 @@ func attempt_disconnect(from_node_name: StringName, from_port: int, to_node_name #var from_output := from_node_renderer.node.get_global_port_idx_from_output(from_port) #var to_input := to_node_renderer.node.get_global_port_idx_from_input(to_port) - deck.disconnect_nodes(from_node_renderer.node, to_node_renderer.node, from_port, to_port) + deck.disconnect_nodes(from_node_renderer.node._id, to_node_renderer.node._id, from_port, to_port) disconnect_node( from_node_renderer.name, @@ -80,6 +103,7 @@ func attempt_disconnect(from_node_name: StringName, from_port: int, to_node_name to_node_renderer.name, to_port ) + dirty = true ## Returns the associated [DeckNodeRendererGraphNode] for the supplied [DeckNode]. ## Or [code]null[/code] if none is found. @@ -96,23 +120,20 @@ func _on_scroll_offset_changed(offset: Vector2) -> void: ## Setups all the data from the set [member deck] in this [DeckRendererGraphEdit] func initialize_from_deck() -> void: + change_dirty = false for i in get_children(): i.queue_free() scroll_offset = deck.get_meta("offset", Vector2()) + is_group = deck.is_group for node_id in deck.nodes: var node_renderer: DeckNodeRendererGraphNode = NODE_SCENE.instantiate() node_renderer.node = deck.nodes[node_id] add_child(node_renderer) node_renderer.position_offset = node_renderer.node.position_as_vector2() - - for node_id in deck.nodes: - var node: DeckNode = deck.nodes[node_id] - var from_node = get_children().filter( - func(c: DeckNodeRendererGraphNode): - return c.node._id == node_id - )[0] + change_dirty = true + dirty = false refresh_connections() @@ -127,26 +148,22 @@ func refresh_connections() -> void: )[0] for from_port in node.outgoing_connections: - for connection in node.outgoing_connections[from_port]: - var to_node_id = connection.keys()[0] - var to_node_port = connection.values()[0] - var to_node: DeckNodeRendererGraphNode = get_children().filter( + for to_node_id: String in node.outgoing_connections[from_port]: + var to_node_ports = node.outgoing_connections[from_port][to_node_id] + var renderer: Array = get_children().filter( func(c: DeckNodeRendererGraphNode): return c.node._id == to_node_id - )[0] - #print("***") - #print("calling connect_node with:") - #print(from_node.node.name) - #print(from_node.node.get_port_type_idx_from_global(from_port)) - #print(to_node.node.name) - #print(to_node.node.get_port_type_idx_from_global(to_node_port)) - #print("***") - connect_node( - from_node.name, - from_port, - to_node.name, - to_node_port ) + if renderer.is_empty(): + break + var to_node: DeckNodeRendererGraphNode = renderer[0] + for to_node_port: int in to_node_ports: + connect_node( + from_node.name, + from_port, + to_node.name, + to_node_port + ) ## Connected to [signal Deck.node_added], used to instance the required ## [DeckNodeRendererGraphNode] and set it's [member DeckNodeRenderGraphNode.position_offset] @@ -155,6 +172,8 @@ func _on_deck_node_added(node: DeckNode) -> void: inst.node = node add_child(inst) inst.position_offset = inst.node.position_as_vector2() + dirty = true + ## Connected to [signal Deck.node_added], used to remove the specified ## [DeckNodeRendererGraphNode] and queue_free it. @@ -165,6 +184,7 @@ func _on_deck_node_removed(node: DeckNode) -> void: renderer.queue_free() break + dirty = true ## Utility function that gets all [DeckNodeRenderGraphNode]s that are selected ## See [member GraphNode.selected] @@ -178,7 +198,6 @@ func get_selected_nodes() -> Array: ## based off the action "group_nodes". func _gui_input(event: InputEvent) -> void: if event.is_action_pressed("group_nodes") && get_selected_nodes().size() > 0: - print("?") clear_connections() var nodes = get_selected_nodes().map( func(x: DeckNodeRendererGraphNode): @@ -188,6 +207,14 @@ func _gui_input(event: InputEvent) -> void: refresh_connections() get_viewport().set_input_as_handled() + if event.is_action_pressed("rename_node") && get_selected_nodes().size() == 1: + var node: DeckNodeRendererGraphNode = get_selected_nodes()[0] + var pos := get_viewport_rect().position + get_global_mouse_position() + rename_popup.popup_on_parent(Rect2i(pos, rename_popup_size)) + rename_popup.le.size.x = rename_popup_size.x + rename_popup.set_text(node.title) + + ## Handles entering groups with action "enter_group". Done here to bypass neighbor ## functionality. func _input(event: InputEvent) -> void: @@ -197,10 +224,21 @@ func _input(event: InputEvent) -> void: if event.is_action_pressed("enter_group") && get_selected_nodes().size() == 1: if !((get_selected_nodes()[0] as DeckNodeRendererGraphNode).node.node_type == "group_node"): return - print("tried to enter group") group_enter_requested.emit((get_selected_nodes()[0] as DeckNodeRendererGraphNode).node.group_id) get_viewport().set_input_as_handled() + +func _on_rename_popup_rename_confirmed(new_name: String) -> void: + var node: DeckNodeRendererGraphNode = get_selected_nodes()[0] + node.title = new_name + node.node.rename(new_name) + dirty = true + + +func _on_rename_popup_closed() -> void: + pass + + ## Opens [member search_popup_panel] at the mouse position. Connected to [signal GraphEdit.popup_request] func _on_popup_request(p_popup_position: Vector2) -> void: var p := get_viewport_rect().position + get_global_mouse_position() @@ -223,3 +261,93 @@ func _on_add_node_menu_node_selected(type: String) -> void: get_node_renderer(node).position_offset = node_pos search_popup_panel.hide() + + +func _on_delete_nodes_request(nodes: Array[StringName]) -> void: + var node_ids := nodes.map( + func(n: StringName): + return (get_node(NodePath(n)) as DeckNodeRendererGraphNode).node._id + ) + + clear_connections() + + if node_ids.is_empty(): + return + + for node_id in node_ids: + deck.remove_node(node_id, true) + + refresh_connections() + dirty = true + + +func _on_copy_nodes_request() -> void: + var selected := get_selected_nodes() + if selected.is_empty(): + return + + var selected_ids: Array[String] = [] + selected_ids.assign(selected.map( + func(x: DeckNodeRendererGraphNode): + return x.node._id + )) + + DisplayServer.clipboard_set(deck.copy_nodes_json(selected_ids)) + + +func _on_paste_nodes_request() -> void: + var clip := DisplayServer.clipboard_get() + var node_pos := (get_local_mouse_position() + scroll_offset - (Vector2(75.0, 0.0) * zoom)) / zoom + + if snapping_enabled: + node_pos = node_pos.snapped(Vector2(snapping_distance, snapping_distance)) + + clear_connections() + + deck.paste_nodes_from_json(clip, node_pos) + + refresh_connections() + dirty = true + + +func _on_duplicate_nodes_request() -> void: + var selected := get_selected_nodes() + if selected.is_empty(): + return + + var selected_ids: Array[String] = [] + selected_ids.assign(selected.map( + func(x: DeckNodeRendererGraphNode): + return x.node._id + )) + + clear_connections() + + deck.duplicate_nodes(selected_ids) + + refresh_connections() + dirty = true + + +class RenamePopup extends Popup: + var le := LineEdit.new() + + signal rename_confirmed(new_text: String) + + func _ready() -> void: + add_child(le) + le.placeholder_text = "New Name" + le.text_submitted.connect( + func(new_text: String): + if new_text.is_empty(): + return + + rename_confirmed.emit(new_text) + hide() + ) + + + func set_text(text: String) -> void: + le.text = text + le.select_all() + le.grab_focus() diff --git a/graph_node_renderer/deck_renderer_graph_edit.tscn b/graph_node_renderer/deck_renderer_graph_edit.tscn index 1d4e361..d941dc1 100644 --- a/graph_node_renderer/deck_renderer_graph_edit.tscn +++ b/graph_node_renderer/deck_renderer_graph_edit.tscn @@ -12,5 +12,9 @@ right_disconnects = true show_arrange_button = false script = ExtResource("1_pojfs") +[connection signal="copy_nodes_request" from="." to="." method="_on_copy_nodes_request"] +[connection signal="delete_nodes_request" from="." to="." method="_on_delete_nodes_request"] +[connection signal="duplicate_nodes_request" from="." to="." method="_on_duplicate_nodes_request"] +[connection signal="paste_nodes_request" from="." to="." method="_on_paste_nodes_request"] [connection signal="popup_request" from="." to="." method="_on_popup_request"] [connection signal="scroll_offset_changed" from="." to="." method="_on_scroll_offset_changed"] diff --git a/graph_node_renderer/logger_renderer.gd b/graph_node_renderer/logger_renderer.gd new file mode 100644 index 0000000..d17c8ab --- /dev/null +++ b/graph_node_renderer/logger_renderer.gd @@ -0,0 +1,46 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends PanelContainer +class_name LoggerRenderer + +@onready var output_label: RichTextLabel = %OutputLabel +@onready var copy_button: Button = %CopyButton +@onready var clear_button: Button = %ClearButton + +const COLORS := { + Logger.LogType.INFO: Color.WHITE, + Logger.LogType.WARN: Color("f4f486"), + Logger.LogType.ERROR: Color("f47c7c"), +} + + +func _ready() -> void: + clear_button.pressed.connect( + func(): + output_label.clear() + output_label.text = "" + output_label.push_context() + ) + DeckHolder.logger.log_message.connect(_on_logger_log_message) + output_label.push_context() + + # the copy button is disabled for now because it doesnt work + copy_button.pressed.connect( + func(): + var c = output_label.get_text() + DisplayServer.clipboard_set(output_label.get_text()) + ) + + +func _on_logger_log_message(text: String, type: Logger.LogType, category: Logger.LogCategory) -> void: + output_label.pop_context() + output_label.push_context() + var category_text: String = Logger.LogCategory.keys()[category].capitalize() + category_text = "(%s)" % category_text + output_label.push_bold() + output_label.add_text("%s: " % category_text) + output_label.pop() + output_label.push_color(COLORS[type]) + output_label.add_text(text) + output_label.newline() diff --git a/graph_node_renderer/logger_renderer.tscn b/graph_node_renderer/logger_renderer.tscn new file mode 100644 index 0000000..e2c2c6e --- /dev/null +++ b/graph_node_renderer/logger_renderer.tscn @@ -0,0 +1,79 @@ +[gd_scene load_steps=4 format=3 uid="uid://duvh3r740w2p5"] + +[ext_resource type="Script" path="res://graph_node_renderer/logger_renderer.gd" id="1_82rlk"] + +[sub_resource type="SystemFont" id="SystemFont_1dh4a"] +font_names = PackedStringArray("Monospace") + +[sub_resource type="SystemFont" id="SystemFont_0uj5d"] +font_names = PackedStringArray("Monospace") +font_weight = 700 + +[node name="LoggerRenderer" type="PanelContainer"] +anchors_preset = -1 +anchor_right = 0.42016 +anchor_bottom = 0.181704 +offset_right = -0.0240021 +offset_bottom = 0.255997 +script = ExtResource("1_82rlk") +metadata/_edit_use_anchors_ = true + +[node name="MarginContainer" type="MarginContainer" parent="."] +layout_mode = 2 +theme_override_constants/margin_left = 3 +theme_override_constants/margin_top = 3 +theme_override_constants/margin_right = 3 +theme_override_constants/margin_bottom = 3 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"] +layout_mode = 2 + +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Debug Console" + +[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="PanelContainer" type="PanelContainer" parent="MarginContainer/VBoxContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="MarginContainer" type="MarginContainer" parent="MarginContainer/VBoxContainer/HBoxContainer/PanelContainer"] +layout_mode = 2 +theme_override_constants/margin_left = 3 +theme_override_constants/margin_top = 3 +theme_override_constants/margin_right = 3 +theme_override_constants/margin_bottom = 3 + +[node name="OutputLabel" type="RichTextLabel" parent="MarginContainer/VBoxContainer/HBoxContainer/PanelContainer/MarginContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +theme_override_fonts/normal_font = SubResource("SystemFont_1dh4a") +theme_override_fonts/bold_font = SubResource("SystemFont_0uj5d") +theme_override_font_sizes/normal_font_size = 12 +theme_override_font_sizes/bold_font_size = 13 +scroll_following = true +context_menu_enabled = true +threaded = true +selection_enabled = true +deselect_on_focus_loss_enabled = false +drag_and_drop_selection_enabled = false + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/HBoxContainer"] +layout_mode = 2 +alignment = 1 + +[node name="CopyButton" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +text = "Copy" + +[node name="ClearButton" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Clear" diff --git a/graph_node_renderer/obs_websocket_setup_dialog.gd b/graph_node_renderer/obs_websocket_setup_dialog.gd new file mode 100644 index 0000000..1121872 --- /dev/null +++ b/graph_node_renderer/obs_websocket_setup_dialog.gd @@ -0,0 +1,63 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends ConfirmationDialog +class_name OBSWebsocketSetupDialog + +@onready var port_spin_box: SpinBox = %PortSpinBox +@onready var password_line_edit: LineEdit = %PasswordLineEdit +@onready var connect_button: Button = %ConnectButton + +signal connect_button_pressed(state: ConnectionState) + +enum ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, +} +var state: ConnectionState + +@onready var _old_port: int = port_spin_box.value +@onready var _old_password: String = password_line_edit.text + + +func get_port() -> int: + return int(port_spin_box.value) + + +func get_password() -> String: + return password_line_edit.text + + +func set_button_state(p_state: ConnectionState) -> void: + connect_button.disabled = p_state == ConnectionState.CONNECTING + state = p_state + + match p_state: + ConnectionState.DISCONNECTED: + connect_button.text = "Connect" + ConnectionState.CONNECTING: + connect_button.text = "Connecting..." + ConnectionState.CONNECTED: + connect_button.text = "Disconnect" + + +func _ready() -> void: + connect_button.pressed.connect( + func(): + connect_button_pressed.emit(state) + ) + + canceled.connect( + func(): + print("canceled") + port_spin_box.value = float(_old_port) + password_line_edit.text = _old_password + ) + + confirmed.connect( + func(): + print("confirmed") + _old_port = int(port_spin_box.value) + _old_password = password_line_edit.text + ) diff --git a/graph_node_renderer/obs_websocket_setup_dialog.tscn b/graph_node_renderer/obs_websocket_setup_dialog.tscn new file mode 100644 index 0000000..3cfd516 --- /dev/null +++ b/graph_node_renderer/obs_websocket_setup_dialog.tscn @@ -0,0 +1,51 @@ +[gd_scene load_steps=2 format=3 uid="uid://eioso6jb42jy"] + +[ext_resource type="Script" path="res://graph_node_renderer/obs_websocket_setup_dialog.gd" id="1_6ggla"] + +[node name="OBSWebsocketSetupDialog" type="ConfirmationDialog"] +title = "OBS Websocket Setup" +initial_position = 4 +size = Vector2i(500, 300) +script = ExtResource("1_6ggla") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 492.0 +offset_bottom = 251.0 + +[node name="GridContainer" type="GridContainer" parent="VBoxContainer"] +layout_mode = 2 +columns = 2 + +[node name="Label" type="Label" parent="VBoxContainer/GridContainer"] +layout_mode = 2 +text = "Port" + +[node name="PortSpinBox" type="SpinBox" parent="VBoxContainer/GridContainer"] +unique_name_in_owner = true +layout_mode = 2 +max_value = 25565.0 +value = 4455.0 + +[node name="Label2" type="Label" parent="VBoxContainer/GridContainer"] +layout_mode = 2 +text = "Password" + +[node name="PasswordLineEdit" type="LineEdit" parent="VBoxContainer/GridContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +secret = true + +[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer"] +layout_mode = 2 +theme_override_constants/margin_bottom = 20 + +[node name="CenterContainer" type="CenterContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="ConnectButton" type="Button" parent="VBoxContainer/CenterContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Connect" diff --git a/graph_node_renderer/tab_container_custom.gd b/graph_node_renderer/tab_container_custom.gd index 818b6cc..a7cda3d 100644 --- a/graph_node_renderer/tab_container_custom.gd +++ b/graph_node_renderer/tab_container_custom.gd @@ -1,34 +1,39 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends VBoxContainer class_name TabContainerCustom ## Custom Recreation of [TabContainer] for Flexibility ## -## Allows for more customizability within the [TabBar] thats used mainly. Extra buttons etc. +## Alternative to [TabContainer]. Instead of using the tree hierarchy to add tabs directly, +## tabs must be created by script. -## Reference to the [TabBar] at the top of the Container. +## Reference to the [TabBar] at the top of the container. @onready var tab_bar: TabBar = %TabBar ## Reference to the [Button] at the end of the [TabBar] that's -## used for adding new Tabs +## used for adding new tabs. @onready var add_tab_button: Button = %Button -## Reference to the [MarginContainer] around the Tabs Contents. +## Reference to the [MarginContainer] around the tab's contents. @onready var content_container: MarginContainer = %ContentContainer -## Emitted when the add [Button] within [member tab_bar] is pressed +## Emitted when the add [Button] within [member tab_bar] is pressed. signal add_button_pressed ## Emitted when the current tab in [member tab_bar] is changed. signal tab_changed(tab: int) ## Emitted when a tab in [member tab_bar] has been closed. ## See [signal TabBar.tab_close_requested] signal tab_closed(tab: int) -## Emitted when a request to close a tab in the [member tab_bar] has been -## requested using [signal TabBar.tab_close_pressed] +## Emitted when a request to close a tab in the [member tab_bar] has been requested. signal tab_close_requested(tab: int) ## Emitted when the order of the tabs in [member tab_bar] has been changed. signal tab_rearranged(old: int, new: int) -## Holds the previously active tab in [member tab_bar] +# Holds the previously active tab in the internal tab_bar var _previous_active_tab: int = -1 +var _tab_metadata: Array[Dictionary] #Array[Dictionary[String -> key, Variant]] + func _ready() -> void: tab_bar.tab_selected.connect( @@ -53,49 +58,84 @@ func _ready() -> void: tab_bar.active_tab_rearranged.connect( func(idx_to: int): + var old := _tab_metadata[_previous_active_tab] + var new := _tab_metadata[idx_to] + _tab_metadata[idx_to] = old + _tab_metadata[_previous_active_tab] = new tab_rearranged.emit(_previous_active_tab, idx_to) content_container.move_child(content_container.get_child(_previous_active_tab), idx_to) _previous_active_tab = idx_to ) -## Adds the given [Node] as "content" for the given tabs name as a [String] -func add_content(c: Node, tab_title: String) -> void: +## Adds the given [Node] as the displayed content for a tab. +func add_content(c: Node, tab_title: String) -> int: tab_bar.add_tab(tab_title) content_container.add_child(c) - tab_bar.set_current_tab(tab_bar.tab_count - 1) + #tab_bar.set_current_tab(tab_bar.tab_count - 1) + _tab_metadata.append({}) + return tab_bar.tab_count - 1 -## Returns the count of tabs in [member tab_bar] + +## Updates the tab at index [param tab_idx]'s title to [param title]. +func set_tab_title(tab_idx: int, title: String) -> void: + tab_bar.set_tab_title(tab_idx, title) + + +func get_tab_title(tab_idx: int) -> String: + return tab_bar.get_tab_title(tab_idx) + + +## Returns the number of tabs. func get_tab_count() -> int: return tab_bar.tab_count -## Returns [code]true[/code] if [method get_tab_count] returns 0. + +## Returns [code]true[/code] if the tab bar has no tabs. func is_empty() -> bool: return get_tab_count() == 0 -## Closes the tab that is at the given [param tab] in [member tab_bar] + +## Closes a tab at the index [param tab]. func close_tab(tab: int) -> void: content_container.get_child(tab).queue_free() if !tab_bar.select_previous_available(): tab_bar.select_next_available() tab_bar.remove_tab(tab) + _tab_metadata.remove_at(tab) tab_closed.emit(tab) if tab_bar.tab_count == 0: _previous_active_tab = -1 -## Returns the currently selected tab in [member tab_bar] + +## Returns the currently selected tab. func get_current_tab() -> int: return tab_bar.current_tab -## Returns the child of [member content_container] at the given [param idx] + +## Sets the current tab to the tab at [param idx]. +func set_current_tab(idx: int) -> void: + tab_bar.current_tab = idx + + +## Returns the child of [member content_container] at the [param idx]. func get_content(idx: int) -> Control: return content_container.get_child(idx) -## Sets the metadata value for the tab at index [param tab_idx], which can be -## retrieved later using [method TabBar.get_tab_metadata()] -func set_tab_metadata(tab: int, metadata: Variant) -> void: - tab_bar.set_tab_metadata(tab, metadata) -## Returns the metadata value set to the tab at index [param tab_idx] using set_tab_metadata(). -## If no metadata was previously set, returns null by default. -func get_tab_metadata(tab: int) -> Variant: - return tab_bar.get_tab_metadata(tab) +## Sets the metadata value for the tab at index [param tab_idx] at [param key], which can be +## retrieved later using [method get_tab_metadata]. +func set_tab_metadata(tab: int, key: String, value: Variant) -> void: + #var m = _tab_metadata.get(tab, {}) + #m[key] = value + #_tab_metadata[tab] = m + var m := _tab_metadata[tab] + m[key] = value + + +## Returns the metadata value set to the tab at index [param tab_idx] using [method set_tab_metadata]. +## If no metadata was previously set, returns [code]null[/code] by default. +func get_tab_metadata(tab: int, key: String, default: Variant = null) -> Variant: + if _tab_metadata.size() - 1 < tab: + return default + var m = _tab_metadata[tab] + return m.get(key, default) diff --git a/graph_node_renderer/twitch_setup_dialog.gd b/graph_node_renderer/twitch_setup_dialog.gd new file mode 100644 index 0000000..34ca81a --- /dev/null +++ b/graph_node_renderer/twitch_setup_dialog.gd @@ -0,0 +1,21 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends ConfirmationDialog +class_name TwitchSetupDialog + +func _ready(): + + %Authenticate.pressed.connect(authenticate_twitch) + confirmed.connect(connect_to_chat) + + +func authenticate_twitch(): + + Connections.twitch.authenticate_with_twitch(%Client_ID.text) + + +func connect_to_chat(): + + Connections.twitch.setup_chat_connection(%Default_Chat.text) + diff --git a/graph_node_renderer/twitch_setup_dialog.tscn b/graph_node_renderer/twitch_setup_dialog.tscn new file mode 100644 index 0000000..5af342f --- /dev/null +++ b/graph_node_renderer/twitch_setup_dialog.tscn @@ -0,0 +1,49 @@ +[gd_scene load_steps=2 format=3 uid="uid://bq2lxmbnic4lc"] + +[ext_resource type="Script" path="res://graph_node_renderer/twitch_setup_dialog.gd" id="1_xx7my"] + +[node name="twitch_setup_dialog" type="ConfirmationDialog"] +title = "Twitch Connection Setup" +position = Vector2i(0, 36) +size = Vector2i(375, 158) +min_size = Vector2i(375, 150) +script = ExtResource("1_xx7my") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -179.5 +offset_top = 8.0 +offset_right = 179.5 +offset_bottom = 109.0 +grow_horizontal = 2 +size_flags_horizontal = 4 + +[node name="Authentication" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="Client_ID" type="LineEdit" parent="VBoxContainer/Authentication"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +tooltip_text = "The Twitch Client ID used by this Connection for it's Authentication. Defaults to the built in Client ID that the program ships with." +placeholder_text = "Custom Client ID (Optional)" + +[node name="Authenticate" type="Button" parent="VBoxContainer/Authentication"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_stretch_ratio = 0.25 +text = "Authenticate" + +[node name="CheckBox" type="CheckBox" parent="VBoxContainer"] +layout_mode = 2 +disabled = true +button_pressed = true +text = "Extra Chat Info" + +[node name="Default_Chat" type="LineEdit" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "Default Chat Channel" diff --git a/graph_node_renderer/unsaved_changes_dialog.tscn b/graph_node_renderer/unsaved_changes_dialog.tscn new file mode 100644 index 0000000..85092a2 --- /dev/null +++ b/graph_node_renderer/unsaved_changes_dialog.tscn @@ -0,0 +1,16 @@ +[gd_scene format=3 uid="uid://cvvkj138fg8jg"] + +[node name="UnsavedChangesDialog" type="ConfirmationDialog"] +initial_position = 2 +size = Vector2i(391, 206) +ok_button_text = "Quit without saving" + +[node name="Label" type="Label" parent="."] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 383.0 +offset_bottom = 157.0 +text = "You have unsaved changes. Quitting now will discard them. Are you sure you want to quit?" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 3 diff --git a/graph_node_renderer/unsaved_changes_dialog_single_deck.gd b/graph_node_renderer/unsaved_changes_dialog_single_deck.gd new file mode 100644 index 0000000..3ae156e --- /dev/null +++ b/graph_node_renderer/unsaved_changes_dialog_single_deck.gd @@ -0,0 +1,9 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +extends ConfirmationDialog +class_name UnsavedChangesDialogSingleDeck + + +func _init() -> void: + add_button("Don't Save", true, "force_close") diff --git a/graph_node_renderer/unsaved_changes_dialog_single_deck.tscn b/graph_node_renderer/unsaved_changes_dialog_single_deck.tscn new file mode 100644 index 0000000..92b3cae --- /dev/null +++ b/graph_node_renderer/unsaved_changes_dialog_single_deck.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=2 format=3 uid="uid://cuwou2aa7qfc2"] + +[ext_resource type="Script" path="res://graph_node_renderer/unsaved_changes_dialog_single_deck.gd" id="1_qpf7a"] + +[node name="UnsavedChangesDialogSingleDeck" type="ConfirmationDialog"] +initial_position = 2 +size = Vector2i(356, 206) +ok_button_text = "Save" +script = ExtResource("1_qpf7a") + +[node name="Label" type="Label" parent="."] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 348.0 +offset_bottom = 157.0 +text = "This deck has unsaved changes. Closing it will discard any changes. +Save now?" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 3 diff --git a/img/.gdignore b/img/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/img/example1.png b/img/example1.png new file mode 100644 index 0000000000000000000000000000000000000000..92361284e6b22657d361bec3fcdbfd8a80647e29 GIT binary patch literal 68019 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 k@CCWkU;V%QC+*Kvlk&vq?6m7Gg;7*&tLfz4 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 AbpQYW literal 0 HcmV?d00001 diff --git a/port_drawer.gd b/port_drawer.gd deleted file mode 100644 index 7c500d3..0000000 --- a/port_drawer.gd +++ /dev/null @@ -1,54 +0,0 @@ -extends HBoxContainer - -@onready var left_slot: ColorRect = $LeftSlot -@onready var right_slot: ColorRect = $RightSlot - -var text: String - -signal button_pressed - - -func set_input_enabled(enabled: bool) -> void: - left_slot.visible = enabled - - -func set_output_enabled(enabled: bool) -> void: - right_slot.visible = enabled - - -func add_label(text: String) -> void: - var l := Label.new() - add_child(l) - l.text = text - move_child(l, 1) - l.size_flags_horizontal = Control.SIZE_EXPAND_FILL - - -func add_field(placeholder: String = "") -> void: - var le := LineEdit.new() - add_child(le) - move_child(le, 1) - le.size_flags_horizontal = Control.SIZE_EXPAND_FILL - le.placeholder_text = placeholder - - le.text_changed.connect( - func(new_text: String): - text = new_text - ) - - -func get_text() -> String: - return text - - -func add_button(text: String) -> void: - var b := Button.new() - b.text = text - add_child(b) - move_child(b, 1) - b.size_flags_horizontal = Control.SIZE_EXPAND_FILL - - b.pressed.connect( - func(): - button_pressed.emit() - ) diff --git a/port_drawer.tscn b/port_drawer.tscn deleted file mode 100644 index a7bb465..0000000 --- a/port_drawer.tscn +++ /dev/null @@ -1,16 +0,0 @@ -[gd_scene load_steps=2 format=3 uid="uid://ddqtmahfxel26"] - -[ext_resource type="Script" path="res://port_drawer.gd" id="1_wot5w"] - -[node name="PortDrawer" type="HBoxContainer"] -script = ExtResource("1_wot5w") - -[node name="LeftSlot" type="ColorRect" parent="."] -visible = false -custom_minimum_size = Vector2(12, 12) -layout_mode = 2 - -[node name="RightSlot" type="ColorRect" parent="."] -visible = false -custom_minimum_size = Vector2(12, 12) -layout_mode = 2 diff --git a/project.godot b/project.godot index 9c05b80..6b1e049 100644 --- a/project.godot +++ b/project.godot @@ -10,22 +10,23 @@ config_version=5 [application] -config/name="Re-DotDeck" +config/name="StreamGraph" +config/version="0.0.1" config/tags=PackedStringArray("dot_deck") run/main_scene="res://graph_node_renderer/deck_holder_renderer.tscn" config/use_custom_user_dir=true -config/custom_user_dir_name="dotdeck" +config/custom_user_dir_name="streamgraph" +config/auto_accept_quit=false config/features=PackedStringArray("4.2", "Forward Plus") -run/low_processor_mode=true -config/icon="res://icon.svg" +config/icon="res://dist/logo-flattened.svg" [autoload] NodeDB="*res://classes/deck/node_db.gd" -[display] +[editor_plugins] -window/subwindows/embed_subwindows=false +enabled=PackedStringArray("res://addons/no-obs-ws/plugin.cfg", "res://addons/no_twitch/plugin.cfg") [input] @@ -39,6 +40,16 @@ enter_group={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"echo":false,"script":null) ] } +rename_node={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194333,"physical_keycode":0,"key_label":0,"unicode":0,"echo":false,"script":null) +] +} +toggle_console={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":78,"key_label":0,"unicode":110,"echo":false,"script":null) +] +} [rendering] diff --git a/script_templates/DeckNode/node_template.gd b/script_templates/DeckNode/node_template.gd index 8346316..27840d7 100644 --- a/script_templates/DeckNode/node_template.gd +++ b/script_templates/DeckNode/node_template.gd @@ -1,3 +1,6 @@ +# (c) 2023-present Eroax +# (c) 2023-present Yagich +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends DeckNode diff --git a/test_node_renderer.gd b/test_node_renderer.gd deleted file mode 100644 index f8938e3..0000000 --- a/test_node_renderer.gd +++ /dev/null @@ -1,53 +0,0 @@ -extends PanelContainer - -@onready var name_label: Label = %NameLabel -@onready var elements_container: VBoxContainer = %ElementsContainer - -var node: DeckNode -const PortDrawer := preload("res://port_drawer.gd") -var port_drawer_scene := preload("res://port_drawer.tscn") - -# THIS IS SUPER JANK AND A HACK FOR DEMONSTRATION PURPOSES -# PLEASE DO NOT ACTUALLY DO ANYTHING THIS CLASS DOES -# IN THE REAL PROJECT !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -func _ready() -> void: - name_label.text = node.name - for i in node.input_ports.size(): - var input_port := node.input_ports[i] - var port_drawer: PortDrawer = port_drawer_scene.instantiate() - elements_container.add_child(port_drawer) - port_drawer.set_input_enabled(true) - match input_port.descriptor: - "field": - port_drawer.add_field(input_port.label) - input_port.value_callback = port_drawer.get_text - "button": - port_drawer.add_button(input_port.label) - port_drawer.button_pressed.connect(func(): - node._receive(i, DeckType.DeckTypeString.new("memes")) - ) - _: - port_drawer.add_label(input_port.label) - - for i in node.output_ports.size(): - if elements_container.get_child_count() - 1 < i: - var pd: PortDrawer = port_drawer_scene.instantiate() - elements_container.add_child(pd) - var port_drawer: PortDrawer = elements_container.get_child(i) - port_drawer.set_output_enabled(true) - var output_port := node.output_ports[i] - match output_port.descriptor: - "field": - port_drawer.add_field(output_port.label) - output_port.value_callback = port_drawer.get_text - "button": - port_drawer.add_button(output_port.label) - port_drawer.button_pressed.connect(func(): - node.send(i, DeckType.DeckTypeBool.new(true)) - ) - _: - port_drawer.add_label(output_port.label) - - - diff --git a/test_node_renderer.tscn b/test_node_renderer.tscn deleted file mode 100644 index d50f9da..0000000 --- a/test_node_renderer.tscn +++ /dev/null @@ -1,39 +0,0 @@ -[gd_scene load_steps=2 format=3 uid="uid://ch8s1d7vobhi4"] - -[ext_resource type="Script" path="res://test_node_renderer.gd" id="1_85wy1"] - -[node name="TestNodeRenderer" type="PanelContainer"] -custom_minimum_size = Vector2(300, 0) -anchors_preset = -1 -anchor_right = 0.26 -anchor_bottom = 0.34 -offset_right = 0.47998 -offset_bottom = -0.320007 -script = ExtResource("1_85wy1") - -[node name="VBoxContainer" type="VBoxContainer" parent="."] -layout_mode = 2 - -[node name="HSeparator" type="HSeparator" parent="VBoxContainer"] -layout_mode = 2 - -[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] -layout_mode = 2 - -[node name="ColorRect" type="ColorRect" parent="VBoxContainer/HBoxContainer"] -custom_minimum_size = Vector2(4, 0) -layout_mode = 2 -color = Color(1, 1, 0.34902, 1) - -[node name="NameLabel" type="Label" parent="VBoxContainer/HBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 -text = "Name" - -[node name="HSeparator2" type="HSeparator" parent="VBoxContainer"] -layout_mode = 2 - -[node name="ElementsContainer" type="VBoxContainer" parent="VBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_vertical = 3