├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── .mergify.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── docs ├── Makefile ├── make.bat └── source │ ├── client.rst │ ├── conf.py │ ├── general.rst │ ├── index.rst │ ├── modules.rst │ ├── nisse.md │ ├── server.rst │ ├── server_commands.rst │ ├── server_functionality.rst │ ├── skyhook.modules.rst │ ├── skyhook.rst │ └── unreal_engine_server.rst ├── examples ├── blender_client.py └── server_in_blender.py ├── icon ├── sky_hook_logo.png └── sky_hook_logo.psd ├── package.py ├── setup.py ├── skyhook ├── __init__.py ├── client.py ├── constants.py ├── logger.py ├── modules │ ├── __init__.py │ ├── blender.py │ ├── core.py │ ├── houdini.py │ ├── maya_mod.py │ ├── substance_painter.py │ └── unreal.py ├── resources │ ├── __init__.py │ ├── favicon.ico │ ├── sky_hook_label_transparent.png │ └── sky_hook_logo_transparent.png └── server.py └── wiki-images ├── UE_Logo_Icon_Black.png ├── blender_logo.png ├── dragon_martin_woortman.png ├── houdinibadge.jpg ├── houdinibadge.png ├── maya_logo.png └── substance_painter.png /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Documentation for this file can be found on the GitHub website here: 2 | # https://docs.github.com/en/free-pro-team@latest/github/creating-cloning-and-archiving-repositories/about-code-owners 3 | # 4 | # TODO: uncomment this line and set the maintainers' GitHub usernames 5 | * @nielsvaes 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Device:** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Checklist 2 | 3 | * [ ] I have read the [Contributor Guide](../../CONTRIBUTING.md) 4 | * [ ] I have read and agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) 5 | * [ ] I have added a description of my changes and why I'd like them included in the section below 6 | 7 | ### Description of Changes 8 | 9 | Describe your changes here 10 | 11 | ### Related Issues 12 | 13 | List related issues here 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | 5 | # Created by .ignore support plugin (hsz.mobi) 6 | ### Python template 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # celery beat schedule file 100 | celerybeat-schedule 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | .idea 133 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatic merge when CI passes and 1 reviews 3 | conditions: 4 | - "#approved-reviews-by>=1" 5 | - "#review-requested=0" 6 | - "#changes-requested-reviews-by=0" 7 | - base=main 8 | actions: 9 | merge: 10 | method: squash 11 | - name: delete head branch after merge 12 | conditions: 13 | - merged 14 | actions: 15 | delete_head_branch: {} 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.1.1] - 2019-09-03 10 | ### Added 11 | - New features go here in a bullet list 12 | 13 | ### Changed 14 | - Changes to existing functionality go here in a bullet list 15 | 16 | ### Deprecated 17 | - Mark features soon-to-be removed in a bullet list 18 | 19 | ### Removed 20 | - Features that have been removed in a bullet list 21 | 22 | ### Fixed 23 | - Bug fixes in a bullet list 24 | 25 | ### Security 26 | - Changes/fixes related to security vulnerabilities in a bullet list 27 | 28 | ## [0.1.0] - 2019-09-02 29 | ### Added 30 | - Initial add of the thing 31 | 32 | [Unreleased]: https://github.com/EmbarkStudios/$REPO_NAME/compare/0.1.1...HEAD 33 | [0.1.1]: https://github.com/EmbarkStudios/$REPO_NAME/compare/0.1.0...0.1.1 34 | [0.1.0]: https://github.com/EmbarkStudios/$REPO_NAME/releases/tag/0.1.0 35 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@embark-studios.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Embark Contributor Guidelines 2 | 3 | Welcome! This project is created by the team at [Embark Studios](https://embark.games). We're glad you're interested in contributing! We welcome contributions from people of all backgrounds who are interested in making great software with us. 4 | 5 | At Embark, we aspire to empower everyone to create interactive experiences. To do this, we're exploring and pushing the boundaries of new technologies, and sharing our learnings with the open source community. 6 | 7 | If you have ideas for collaboration, email us at opensource@embark-studios.com. 8 | 9 | We're also hiring full-time engineers to work with us in Stockholm! Check out our current job postings [here](https://www.embark-studios.com/jobs). 10 | 11 | ## Issues 12 | 13 | ### Feature Requests 14 | 15 | If you have ideas or how to improve our projects, you can suggest features by opening a GitHub issue. Make sure to include details about the feature or change, and describe any uses cases it would enable. 16 | 17 | Feature requests will be tagged as `enhancement` and their status will be updated in the comments of the issue. 18 | 19 | ### Bugs 20 | 21 | When reporting a bug or unexpected behaviour in a project, make sure your issue describes steps to reproduce the behaviour, including the platform you were using, what steps you took, and any error messages. 22 | 23 | Reproducible bugs will be tagged as `bug` and their status will be updated in the comments of the issue. 24 | 25 | ### Wontfix 26 | 27 | Issues will be closed and tagged as `wontfix` if we decide that we do not wish to implement it, usually due to being misaligned with the project vision or out of scope. We will comment on the issue with more detailed reasoning. 28 | 29 | ## Contribution Workflow 30 | 31 | ### Open Issues 32 | 33 | If you're ready to contribute, start by looking at our open issues tagged as [`help wanted`](../../issues?q=is%3Aopen+is%3Aissue+label%3A"help+wanted") or [`good first issue`](../../issues?q=is%3Aopen+is%3Aissue+label%3A"good+first+issue"). 34 | 35 | You can comment on the issue to let others know you're interested in working on it or to ask questions. 36 | 37 | ### Making Changes 38 | 39 | 1. Fork the repository. 40 | 41 | 2. Create a new feature branch. 42 | 43 | 3. Make your changes. Ensure that there are no build errors by running the project with your changes locally. 44 | 45 | 4. Open a pull request with a name and description of what you did. You can read more about working with pull requests on GitHub [here](https://help.github.com/en/articles/creating-a-pull-request-from-a-fork). 46 | 47 | 5. A maintainer will review your pull request and may ask you to make changes. 48 | 49 | ## Code Guidelines 50 | 51 | ### Rust 52 | 53 | You can read about our standards and recommendations for working with Rust [here](https://github.com/EmbarkStudios/rust-ecosystem/blob/main/guidelines.md). 54 | 55 | ### Python 56 | 57 | We recommend following [PEP8 conventions](https://www.python.org/dev/peps/pep-0008/) when working with Python modules. 58 | 59 | ### JavaScript & TypeScript 60 | 61 | We use [Prettier](https://prettier.io/) with the default settings to auto-format our JavaScript and TypeScript code. 62 | 63 | ## Licensing 64 | 65 | Unless otherwise specified, all Embark open source projects are licensed under a dual MIT OR Apache-2.0 license, allowing licensees to chose either at their option. You can read more in each project's respective README. 66 | 67 | ## Code of Conduct 68 | 69 | Please note that our projects are released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md) to ensure that they are welcoming places for everyone to contribute. By participating in any Embark open source project, you agree to abide by these terms. 70 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Embark Studios 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌴 SkyHook 2 | 3 | [![Embark](https://img.shields.io/badge/embark-open%20source-blueviolet.svg)](https://embark.dev) 4 | 5 | ## Engine and DCC communication system 6 | 7 | SkyHook was created to facilitate communication between DCCs, standalone applications, web browsers and game engines. As of right now, it’s working in Houdini, Blender, Maya, Substance Painter and Unreal Engine. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | The current mainline version is for Python 3.6 and up. 20 | 21 | SkyHook consist of 2 parts that can, but don’t have to, work together. There’s a client and a server. The server is just a very simple HTTP server that takes JSON requests. It parses those requests and tries to execute what was in them. The client just makes a a POST request to the server with a JSON payload. This is why you can basically use anything that’s able to do so as a client. Could be in a language other than Python, or even just webpage or mobile application. 22 | 23 | # Release 3.0 24 | I removed dependencies on PySide altogether, since they were causing unnecessary bloat to the package. 25 | 26 | # Quick Start 27 | Some quick start examples to help you get on your way. 28 | 29 | ## Pip installing 30 | 31 | You should be able to pip install this package like this: 32 | ```batch 33 | pip install --upgrade git+https://github.com/EmbarkStudios/skyhook 34 | ``` 35 | 36 | ## Maya 37 | Let's say you have a file called `skyhook_commands.py` that is available in `sys.path` inside of Maya. The following are some example functions you could have: 38 | 39 | `skyhook_commands.py` 40 | 41 | ```python 42 | import os 43 | import maya.cmds as cmds 44 | import pymel.core as pm 45 | 46 | def delete_namespace(namespace, nuke_all_contents=False): 47 | """ 48 | Removes all objects from the given namespace and then removes it. 49 | If given the nuke flag, will delete all objects from the namespace too. 50 | 51 | :param namespace: *string* 52 | :param nuke_all_contents: *bool* whether to also delete all objects contained in namespace and their children 53 | :return: 54 | """ 55 | try: 56 | if nuke_all_contents is not True: 57 | pm.namespace(force=True, rm=namespace, mergeNamespaceWithRoot=True) 58 | pm.namespace (set=':') 59 | else: 60 | nmspc_children = cmds.namespaceInfo(namespace, listOnlyNamespaces=True, recurse=True) 61 | if nmspc_children: 62 | # negative element count to get them sorted from deepest to shallowest branch. 63 | sorted_name_list = sorted(nmspc_children, key=lambda element: -element.count(":")) 64 | for obj in sorted_name_list: 65 | cmds.namespace(removeNamespace=obj, deleteNamespaceContent=True) 66 | cmds.namespace(removeNamespace=namespace, deleteNamespaceContent=True) 67 | return True 68 | except Exception as err: 69 | print(err) 70 | return False 71 | 72 | def get_scene_path(full_path=True, name_only=False, folder_only=False, extension=True): 73 | """ 74 | Extension of the normal pm.sceneName() with a bit more options 75 | 76 | :param full_path: *bool* returns the full path (D:/Game/scenes/enemy.ma) 77 | :param name_only: *bool* returns the name of the file only (enemy.ma) 78 | :param folder_only: *bool* returns the folder of the file only (D:/Game/scenes) 79 | :param extension: *bool* whether or not to return the name with the extension 80 | :return: *string* 81 | """ 82 | if name_only: 83 | name = os.path.basename(pm.sceneName()) 84 | if extension: 85 | return name 86 | return os.path.splitext(name)[0] 87 | if folder_only: 88 | return os.path.dirname(pm.sceneName()) 89 | if full_path: 90 | if extension: 91 | return pm.sceneName() 92 | return os.path.splitext(pm.sceneName())[0] 93 | return "" 94 | 95 | 96 | def selection(as_strings=False, as_pynodes=False, st=False, py=False): 97 | """ 98 | Convenience function to easily get your selection as a string representation or as pynodes 99 | 100 | :param as_strings: *bool* returns a list of strings 101 | :param as_pynodes: *bool* returns a list of pynodes 102 | :param st: *bool* same as as_strings 103 | :param py: *bool* same as as_pynodes 104 | :return: 105 | """ 106 | if as_strings or st: 107 | return [str(uni) for uni in cmds.ls(selection=True)] 108 | 109 | if as_pynodes or py: 110 | return pm.selected() 111 | 112 | 113 | ``` 114 | 115 | 116 | Running this code inside Maya sets up a SkyHook server: 117 | ```python 118 | import pprint 119 | import skyhook_commands # this is the file listed above 120 | from skyhook import server as shs 121 | from skyhook.constants import ServerEvents, HostPrograms 122 | 123 | def catch_execution_signal(command, parameters): 124 | """ 125 | This function is ran any time the skyhook server executes a command. You can use this as a callback to do 126 | specific things, if needed. 127 | """ 128 | print(f"I just ran the command {command}") 129 | print("These were the parameters of the command:") 130 | pprint.pprint(parameters) 131 | 132 | if command == "SKY_SHUTDOWN": 133 | print("The shutdown command has been sent and this server will stop accepting requests") 134 | elif command == "get_from_list": 135 | print("Getting this from a list!") 136 | 137 | 138 | # Since we're running this in Maya, we want to make use of a MainThreadExecutor, so that Maya's code gets executed in the 139 | # main (UI) thread. Otherwise Maya can get unstable and crash depending on what it is we're doing. Therefor we'll use the 140 | # `start_executor_server_in_thread` function, pass along "maya" as a host program so that the proper MainThreadExecutor is 141 | # started. 142 | thread, executor, server = shs.start_executor_server_in_thread(host_program=HostPrograms.maya) 143 | if server: 144 | server.hotload_module("maya_mod") # this gets loaded from the skyhook.modules 145 | server.hotload_module(skyhook_commands, is_skyhook_module=False) # passing False to is_skyhook_module, because this files comes from outside the skyhook package 146 | 147 | server = server 148 | executor = executor 149 | thread = thread 150 | 151 | server.events.connect(ServerEvents.exec_command, catch_execution_signal) # call the function `catch_execution_signal` any time this server executes a command through its MainThreadExecutor 152 | server.events.connect(ServerEvents.command, catch_execution_signal) # call the same function any time this server exectures a command outside of its MainThreadExecutor 153 | ``` 154 | 155 | We'll make a `MayaClient` to run any of the functions that we've provided the server with. This can be run as a standalone Python program, or from another piece of software, like Blender or Substance Painter. 156 | 157 | ```python 158 | from skyhook import client 159 | 160 | maya_client = client.MayaClient() 161 | 162 | result = maya_client.execute("selection", {"st": True}) 163 | print(f"The current selection is: {result.get('ReturnValue', None)}" ) 164 | # >> The current selection is: ['actor0:Leg_ctrl.cv[14]', 'actor0:Chest_ctrl', 'actor0:bake:root'] 165 | 166 | result = maya_client.execute("get_scene_path") 167 | print(f"The current scene path is: {result.get('ReturnValue', '')}") 168 | # >> The current scene path is: D:/THEFINALS/MayaScenes/Weapons/AKM/AKM.ma 169 | ``` 170 | 171 | 172 | ## SkyHook server in Unreal 173 | 174 | Unreal is a bit of a different beast. It does support Python for editor related tasks, but seems to be starving any threads pretty quickly. That's why it's pretty much impossible to run the SkyHook server in Unreal like we're able to do so in other programs. However, as explained in the main outline, SkyHook clients don't have to necessarily connect to SkyHook servers. That's why we can use ``skyhook.client.UnrealClient`` with Unreal Engine's built-in Remote Control API. 175 | 176 | Make sure the Remote Control API is loaded from `Edit > Plugins` 177 | 178 | ![image](https://user-images.githubusercontent.com/7821618/165508115-6d6919bd-49c0-4c15-992f-99c00887ac33.png) 179 | 180 | Loading a SkyHook module in Unreal is done by just importing it like normal. Assuming the code for the SkyHook module is in a file called `skyhook_commands` in the Python folder (`/Game/Content/Python`), you can just do: 181 | 182 | 183 | ```python 184 | import skyhook_commands 185 | ``` 186 | 187 | If you want to make sure it's always available when you start the engine, add an `init_unreal.py` file in the Python folder and import the module from there. I've had problems with having a `__pycache__` folder on engine start up, so I just deleted it from that some `init_unreal.py` file: 188 | 189 | 190 | ```python 191 | # making sure skyhook_commands are loaded and ready to go 192 | import skyhook_commands 193 | # adding this here because it's a pain in the ass to retype every time 194 | from importlib import reload 195 | 196 | # cleaning up pycache folder 197 | pycache_folder = os.path.join(os.path.dirname(__file__), "__pycache__") 198 | if os.path.isdir(pycache_folder): 199 | shutil.rmtree(pycache_folder) 200 | ``` 201 | 202 | ## The SkyHook Unreal module 203 | 204 | Unreal has specific requirements to run Python code. So you have to keep that in mind when adding functionality to the SkyHook module. In order for it to be "seen" in Unreal you need to decorate your class and functions with specific Unreal decorators. 205 | 206 | Decorating the class with `unreal.uclass()`: 207 | 208 | ```python 209 | import unreal 210 | 211 | @unreal.uclass() 212 | class SkyHookCommands(unreal.BlueprintFunctionLibrary): 213 | def __init__(self): 214 | super(SkyHookCommands, self).__init__() 215 | ``` 216 | 217 | If you want your function to accept parameters, you need to add them in the decorator like this 218 | 219 | ```python 220 | import unreal 221 | import os 222 | 223 | @unreal.ufunction(params=[str, str], static=true) 224 | def rename_asset(asset_path, new_name): 225 | dirname = os.path.dirname(asset_path) 226 | new_name = dirname + "/" + new_name 227 | unreal.EditorAssetLibrary.rename_asset(asset_path, new_name) 228 | unreal.log_warning("Renamed to %s" % new_name) 229 | ``` 230 | --- 231 | **NOTE** 232 | 233 | You can not use Python's `list` in the decorator for the Unreal functions. Use `unreal.Array(type)`, eg: `unreal.Array(float)`. 234 | 235 | --- 236 | 237 | --- 238 | **NOTE** 239 | 240 | You can not use Python's `dict` in the decorator for the Unreal functions. Use `unreal.Map(key type, value type)`, eg: `unreal.Map(str, int) 241 | 242 | --- 243 | 244 | 245 | ## Returning values to the SkyHook client 246 | 247 | Whenever you want to return anything from Unreal back to your SkyHook client, add the `ret` keyword in the function parameters: 248 | ```python 249 | @unreal.ufunction(params=[str], ret=bool, static=True) 250 | def does_asset_exist(asset_path=""): 251 | """ 252 | Return True or False whether or not asset exists at given path 253 | 254 | :return: *bool* 255 | """ 256 | return_value = unreal.EditorAssetLibrary.does_asset_exist(asset_path) 257 | 258 | return return_value 259 | ``` 260 | 261 | 262 | ## Returning "complex" items from Unreal 263 | 264 | Let's say you want to return a dictionary from Unreal that looks like this: 265 | 266 | ```python 267 | my_dict = { 268 | "player_name": "Nisse", 269 | "player_health": 98.5, 270 | "player_lives": 3, 271 | "is_player_alive": True 272 | } 273 | ``` 274 | 275 | Unfortunately, due to how Unreal works, you can't specify your return value as a dictionary or list with mixed values. This can be quite problematic because sometimes that's exactly what you want. To solve this you can just stringify your return value and it will be decoded into a Python object on the client side. 276 | 277 | So in the example above, you'd do something like 278 | 279 | ```python 280 | @unreal.ufunction(params=[str], ret=str, static=True) 281 | def my_cool_function(player_name=""): 282 | my_dict = { 283 | "player_name": "Nisse", 284 | "player_health": 98.5, 285 | "player_lives": 3, 286 | "is_player_alive": True 287 | } 288 | 289 | return str(my_dict) 290 | ``` 291 | 292 | Since "this will just work", you can basically always just return a string like that. 293 | 294 | 295 | ## Complete example of a `skyhook_commands.py` file in Unreal 296 | 297 | Here's a short snippet of a `skyhook_commands.py` to help you get on your way 298 | 299 | ```python 300 | import unreal 301 | 302 | import os 303 | import pathlib 304 | 305 | @unreal.uclass() 306 | class SkyHookCommands(unreal.BlueprintFunctionLibrary): 307 | 308 | @unreal.ufunction(params=[str], ret=str, static=True) 309 | def create_control_rig_from_skeletal_mesh(skeletal_mesh_path): 310 | skeletal_mesh = unreal.EditorAssetLibrary.load_asset(skeletal_mesh_path) 311 | 312 | # make the blueprint using a the factory 313 | ctrl_rig_blueprint: unreal.ControlRigBlueprint = unreal.ControlRigBlueprintFactory.create_control_rig_from_skeletal_mesh_or_skeleton(selected_object=skeletal_mesh) 314 | 315 | # chuck in a begin execution node 316 | controller: unreal.RigVMController = ctrl_rig_blueprint.get_controller_by_name("RigVMModel") 317 | controller.add_unit_node_from_struct_path(script_struct_path=unreal.RigUnit_BeginExecution.static_struct(), 318 | position=unreal.Vector2D(0.0, 0.0), 319 | node_name="RigUnit_BeginExecution") 320 | 321 | # return the path of the blueprint we just made 322 | return ctrl_rig_blueprint.get_path_name() 323 | 324 | 325 | @unreal.ufunction(params=[str, str, str, bool, bool, int, bool], ret=bool, static=True) 326 | def import_animation(source_path, destination_path, unreal_skeleton_path, replace_existing, save_on_import, sample_rate, show_dialog): 327 | destination_path = match_existing_folder_case(destination_path) 328 | 329 | options = unreal.FbxImportUI() 330 | 331 | options.import_animations = True 332 | options.skeleton = unreal.load_asset(unreal_skeleton_path) 333 | options.anim_sequence_import_data.set_editor_property("animation_length", unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME) 334 | options.anim_sequence_import_data.set_editor_property("remove_redundant_keys", False) 335 | options.anim_sequence_import_data.set_editor_property("custom_sample_rate", sample_rate) 336 | unreal.log_warning(f"Imported using sample rate: {sample_rate}") 337 | # options.anim_sequence_import_data.set_editor_property("use_default_sample_rate", True) 338 | 339 | task = unreal.AssetImportTask() 340 | task.automated = not show_dialog 341 | task.destination_path = destination_path 342 | task.filename = source_path 343 | task.replace_existing = replace_existing 344 | task.save = save_on_import 345 | task.options = options 346 | 347 | unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) 348 | 349 | return True 350 | 351 | @unreal.ufunction(params=[str, str, str], ret=str, static=True) 352 | def create_material_instance(destination_path="", material_name="new_material", parent_path=""): 353 | """ 354 | Create a material instance at the desetination with the provided name and parent material 355 | 356 | :return: *str* asset path of material instance 357 | """ 358 | # Get asset tools 359 | destination_path = match_existing_folder_case(destination_path) 360 | assetTools = unreal.AssetToolsHelpers.get_asset_tools() 361 | 362 | if unreal.EditorAssetLibrary.does_asset_exist(parent_path): 363 | parent_material = unreal.load_asset(parent_path) 364 | else: 365 | print("Could not find parent material " + parent_path) 366 | return "" 367 | 368 | material_instance = assetTools.create_asset(asset_name=material_name, package_path=destination_path, asset_class=unreal.MaterialInstanceConstant, factory=unreal.MaterialInstanceConstantFactoryNew()) 369 | unreal.MaterialEditingLibrary().set_material_instance_parent(material_instance, parent_material) 370 | 371 | return material_instance.get_path_name() 372 | 373 | @unreal.ufunction(params=[str], ret=bool, static=True) 374 | def load_map(filename=""): 375 | """ 376 | Set the position and rotation of the viewport camera 377 | 378 | :return: *bool* true if the action is completed 379 | """ 380 | 381 | # Get path for currently loaded map 382 | current_map = unreal.EditorLevelLibrary.get_editor_world().get_path_name() 383 | 384 | # If the requested map is not loaded, prompt before loading 385 | if filename != current_map: 386 | if show_dialog_confirm("Skyhook Load Map", "Are you sure you want to load this map? Any changes to the current map will be lost!") == unreal.AppReturnType.YES: 387 | unreal.EditorLoadingAndSavingUtils.load_map(filename) 388 | else: 389 | return False 390 | 391 | return True 392 | 393 | @unreal.ufunction(params=[str, unreal.Array(str)], ret=bool, static=True) 394 | def load_map_and_select_actors(filename="", actor_guids=[]): 395 | """ 396 | Set the position and rotation of the viewport camera 397 | 398 | :return: *bool* true if the action is completed 399 | """ 400 | 401 | # Get path for currently loaded map 402 | current_map = unreal.EditorLevelLibrary.get_editor_world().get_path_name() 403 | 404 | # If the requested map is not loaded, prompt before loading 405 | if filename != current_map: 406 | if show_dialog_confirm("Skyhook Load Map", "Are you sure you want to load this map? Any changes to the current map will be lost!") == unreal.AppReturnType.YES: 407 | unreal.EditorLoadingAndSavingUtils.load_map(filename) 408 | else: 409 | return False 410 | 411 | guids = [] 412 | 413 | # Parse guid string to guid struct 414 | for actor_guid in actor_guids: 415 | guids.append(unreal.GuidLibrary.parse_string_to_guid(actor_guid)[0]) 416 | 417 | # Create actor list (STUPID WAY, BUT SEEMS TO BE NO OTHER CHOICE) 418 | # Get all actors in level 419 | level_actors = unreal.EditorLevelLibrary.get_all_level_actors() 420 | actors = [] 421 | 422 | # Compare actor guids 423 | for level_actor in level_actors: 424 | for guid in guids: 425 | if unreal.GuidLibrary.equal_equal_guid_guid(level_actor.get_editor_property('actor_guid'), guid): 426 | actors.append(level_actor) 427 | break 428 | 429 | # Select actors 430 | unreal.EditorLevelLibrary.set_selected_level_actors(actors) 431 | 432 | return True 433 | 434 | @unreal.ufunction(params=[str, unreal.Array(str), bool], ret=str, static=True) 435 | def search_for_asset(root_folder="/Game/", filters=[], search_complete_path=True): 436 | """ 437 | Returns a list of all the assets that include the file filters 438 | 439 | :return: *list* 440 | """ 441 | filtered_assets = [] 442 | 443 | assets = unreal.EditorAssetLibrary.list_assets(root_folder) 444 | 445 | assets = [path for path in assets if path.startswith(root_folder)] 446 | 447 | for asset in assets: 448 | for file_filter in filters: 449 | if not search_complete_path: 450 | if file_filter.lower() in os.path.basename(asset).lower(): 451 | filtered_assets.append(asset) 452 | else: 453 | if file_filter.lower() in asset.lower(): 454 | filtered_assets.append(asset) 455 | 456 | return str(filtered_assets) 457 | 458 | 459 | """ 460 | General helper functions 461 | """ 462 | 463 | def match_existing_folder_case(input_path="/Game/developers"): 464 | """ 465 | Match the case sensitivity of existing folders on disk, so we don't submit perforce paths with mismatched casing 466 | """ 467 | input_path = input_path.replace("\\", "/") 468 | 469 | project_content_path = unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir()) 470 | project_disk_path = pathlib.Path(project_content_path).resolve().as_posix() + "/" 471 | 472 | disk_path = input_path.replace("/Game/", project_disk_path) 473 | 474 | # this is the magic line that makes it match the local disk 475 | case_sensitive_path = pathlib.Path(disk_path).resolve().as_posix() 476 | 477 | matched_path = case_sensitive_path.replace(project_disk_path, "/Game/") 478 | 479 | # make sure trailing slashes is preserved 480 | if input_path.endswith("/"): 481 | matched_path = f"{matched_path}/" 482 | 483 | if matched_path != input_path: 484 | print(f"Incorrectly cased path '{input_path}' was converted to '{matched_path}'") 485 | 486 | return matched_path 487 | 488 | def show_dialog_confirm(title, message): 489 | """ 490 | Show a dialog with YES/NO options. 491 | 492 | :return: *bool* true/false depending on which button is clicked 493 | """ 494 | message_type = unreal.AppMsgType.YES_NO 495 | return_value = unreal.EditorDialog.show_message(title, message, message_type) 496 | 497 | return return_value 498 | 499 | ``` 500 | 501 | # Unreal Client 502 | 503 | A SkyHook client for Unreal works exactly the same as for a normal SkyHook server. 504 | 505 | ```python 506 | from skyhook import client 507 | 508 | client = client.UnrealClient() 509 | 510 | result = client.execute("search_for_asset", {"root_folder": "/Game", "filters": ["SK_Char"]}) 511 | print(result.get("ReturnValue", None)) 512 | 513 | # >> [/Game/ArcRaiders/Characters/SK_Character01.SK_Character01, /Game/ArcRaiders/Characters/SK_Character02.SK_Character02, /Game/ArcRaiders/Props/SK_CharredHusk.SK_CharredHusk] 514 | ``` 515 | 516 | --- 517 | **NOTE** 518 | 519 | Due to how Unreal processes Python commands, you can't skip an argument or keyword argument when calling a function. In the example above, doing something like this will cause an error in Unreal 520 | 521 | ```python 522 | client.execute("search_for_asset", {"filters": ["SK_Char"]}) # not passing the first keyword argument (`root_folder`) 523 | # -> errors out in Unreal 524 | ``` 525 | 526 | --- 527 | 528 | 529 | ## Contributing 530 | 531 | [![Contributor Covenant](https://img.shields.io/badge/contributor%20covenant-v1.4-ff69b4.svg)](../main/CODE_OF_CONDUCT.md) 532 | 533 | We welcome community contributions to this project. 534 | 535 | Please read our [Contributor Guide](CONTRIBUTING.md) for more information on how to get started. 536 | 537 | ## License 538 | 539 | Licensed under either of 540 | 541 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 542 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 543 | 544 | at your option. 545 | 546 | ### Contribution 547 | 548 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. 549 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/client.rst: -------------------------------------------------------------------------------- 1 | SkyHook Client 2 | ==================== 3 | The client uses Python's ``request`` library to send a POST request to the server. The ``Client`` class is built in such a way that it looks practically identical to use with a SkyHook server or Unreal's Web Remote Control. Behind the scenes there's some extra work done to make it all work. So if you're reading this and you use want to use the ``Client`` as is, great! If you want to tinker with the code a bit yourself, be aware that the ``UnrealClient`` needs some TLC :) 4 | 5 | Making a client 6 | ---------------------- 7 | 8 | A SkyHook client only needs to be provided with an address (default is 127.0.0.1) and a port (default is 65500) to connect to. 9 | 10 | Making a SkyHook ``Client`` object is as simple as: 11 | 12 | .. code-block:: python 13 | 14 | import skyhook.client 15 | 16 | client = skyhook.client.Client() 17 | 18 | ``skyhook.constants.Ports`` holds the ports that are set up to use with host programs by default. You can use this if you don't remember which program uses which port. 19 | 20 | .. code-block:: python 21 | 22 | import skyhook.client 23 | from skyhook.constants import Ports 24 | 25 | client = skyhook.client.Client() 26 | client.port = Ports.houdini 27 | 28 | 29 | DCC specific client 30 | --------------------- 31 | 32 | All DCCs have their own named client you can use to make your life easier. 33 | 34 | .. code-block:: python 35 | 36 | import skyhook.client 37 | 38 | client = skyhook.client.BlenderClient() 39 | 40 | That way you don't have to worry about which port to use. 41 | 42 | All DCC clients are basically the same as the base ``Client`` class they inherit from. Unreal is the only odd man out, more on that further down. 43 | 44 | Executing functions on the server 45 | ----------------------------------- 46 | 47 | You use the ``execute`` function send a command to the server. It takes a ``command`` argument and an optional ``parameters`` argument. The ``command`` can be a string, or an imported module. Eg: 48 | 49 | .. code-block:: python 50 | 51 | import skyhook.client 52 | import skyhook.modules.blender as blender_module 53 | 54 | client = skyhook.client.BlenderClient() 55 | client.execute("make_cube") 56 | client.execute(blender_module.export_usd, parameters={"path": "D:/test_file.usda"}) 57 | 58 | The ``parameters`` always have to be a dictionary and the keys have to match the argument names used in the function. If you want to execute a function on a SkyHook server without having access to the code, you can use the ``SKY_FUNCTION_HELP`` command to see what the arguments or keywords are called. For example, the function ``echo_message`` expects an argument called ``message``: 59 | 60 | .. code-block:: python 61 | 62 | import skyhook.client 63 | from skyhook.constants import ServerCommands, Ports 64 | 65 | client = skyhook.client.Client() 66 | client.execute(ServerCommands.SKY_FUNCTION_HELP, parameters={"function_name": "echo_message"}) 67 | 68 | Returns: 69 | 70 | .. code-block:: python 71 | 72 | { 73 | u'Command': u'SKY_FUNCTION_HELP', 74 | u'ReturnValue': {u'arguments': [u'message'], 75 | u'function_name': u'echo_message'}, 76 | u'Success': True, 77 | u'Time': u'19:15:14' 78 | } 79 | 80 | 81 | Adding a module to the server using the client 82 | ----------------------------------- 83 | 84 | One of the ``ServerCommands`` is ``SKY_HOTLOAD``. You can use this to add functionality to the server from the client. 85 | 86 | .. code-block:: python 87 | 88 | import skyhook.client 89 | import skyhook.modules.maya as maya 90 | from skyhook import ServerCommands 91 | 92 | maya_client = skyhook.client.MayaClient() 93 | maya_client.execute(ServerCommands.SKY_HOTLOAD, parameters={"modules": ["embark_tools.Core.maya_utils"], "is_skyhook_module": False}) 94 | 95 | This will bring in all the functions from those modules. 96 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('../../skyhook')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'SkyHook' 21 | copyright = '2021, Niels Vaes' 22 | author = 'Niels Vaes' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '0.0.1' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | "sphinx.ext.autodoc", 35 | "sphinx_rtd_theme", 36 | "recommonmark" # for markdown 37 | ] 38 | 39 | source_suffix = { 40 | '.rst': 'restructuredtext', 41 | '.txt': 'restructuredtext', 42 | '.md': 'markdown', 43 | } 44 | 45 | 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ['_templates'] 49 | 50 | # List of patterns, relative to source directory, that match files and 51 | # directories to ignore when looking for source files. 52 | # This pattern also affects html_static_path and html_extra_path. 53 | exclude_patterns = [] 54 | 55 | 56 | # -- Options for HTML output ------------------------------------------------- 57 | import sphinx_rtd_theme 58 | # The theme to use for HTML and HTML Help pages. See the documentation for 59 | # a list of builtin themes. 60 | # 61 | html_theme = 'sphinx_rtd_theme' 62 | 63 | # Add any paths that contain custom static files (such as style sheets) here, 64 | # relative to this directory. They are copied after the builtin static files, 65 | # so a file named "default.css" will overwrite the builtin "default.css". 66 | html_static_path = ['_static'] -------------------------------------------------------------------------------- /docs/source/general.rst: -------------------------------------------------------------------------------- 1 | General 2 | ======== 3 | 4 | Constants module 5 | ------------------ 6 | The constants module is where SkyHook's constants are stored. This is the place where you would add constants for new default host program or server commands for example. 7 | 8 | The ``Constants`` class in the ``constants`` module is generally where I put things that will make life easier writing code, because you can auto complete to these. 9 | 10 | .. code-block:: python 11 | 12 | class Constants: 13 | """ 14 | Some very general constants that are used throughout SkyHook 15 | """ 16 | function_name = "FunctionName" 17 | parameters = "Parameters" 18 | module = "_Module" 19 | undefined = "undefined" 20 | 21 | 22 | The SkyHook server comes with a couple of commands that are not executed from a module, but on the server itself. These are described in 23 | ``skyhook.constants.ServerCommands``. They always start with "SKY", so don't add any functions with names like this to any module you load into the server. 24 | 25 | .. code-block:: python 26 | 27 | class ServerCommands: 28 | SKY_SHUTDOWN = "SKY_SHUTDOWN" 29 | SKY_LS = "SKY_LS" 30 | SKY_RELOAD_MODULES = "SKY_RELOAD_MODULES" 31 | SKY_HOTLOAD = "SKY_HOTLOAD" 32 | SKY_UNLOAD = "SKY_UNLOAD" 33 | SKY_FUNCTION_HELP = "SKY_FUNCTION_HELP" 34 | 35 | * SKY_SHUTDOWN: Shuts down the server 36 | * SKY_LS: Returns a list with all the function names this server can execute 37 | * SKY_RELOAD_MODULES: Remotely reloads the loaded modules 38 | * SKY_HOTLOAD: needs to be called with the ``skyhook.Constants.module`` argument as a list of module names in the ``parameters``, as explained above. 39 | * SKY_UNLOAD: needs to be called with the ``skyhook.Constants.module`` argument as a list of module names in the ``parameters``, these modules will be removed from the server. 40 | * SKY_FUNCTION_HELP: needs to be called with "function_name" argument as string in the ``parameters``, this will return the names of the arguments, packed arguments and packed keywords a function requires. 41 | 42 | 43 | Their functionality is hardcoded in ``__process_server_command()`` in the ``Server`` class. If you want to add any extra functionality, this is where you do it. -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to SkyHook's documentation! 2 | =================================== 3 | 4 | SkyHook was created to facilitate communication between DCCs, standalone applications, web browsers and game engines. As of right now, it's working in Houdini, Blender, Maya and Unreal Engine. 5 | 6 | .. image:: ../../wiki-images/UE_Logo_Icon_Black.png 7 | :width: 20% 8 | .. image:: ../../wiki-images/blender_logo.png 9 | :width: 20% 10 | .. image:: ../../wiki-images/houdinibadge.png 11 | :width: 20% 12 | .. image:: ../../wiki-images/maya_logo.png 13 | :width: 20% 14 | 15 | 16 | 17 | It works both with Python 2.7.x and 3.x and you don't need to have the same Python version across programs. For example, you can build a standalone application in Python 3.8.5 and use SkyHook to communicate with Maya's Python 2.7. This makes it much easier to use than something like RPyC where even a minor version difference can stop it from working. 18 | 19 | SkyHook consist of 2 parts that can, but don't have to, work together. There's a client and a server. The server is just a very simple HTTP server that takes JSON requests. It parses those requests and tries to call the function that was passed in the request. It returns another JSON dictionary with the result of the outcome of the function. 20 | 21 | The client just makes a a POST request to the server with a JSON payload. This is why you can basically use anything that's able to do so as a client. Could be in a language other than Python, or even just webpage or mobile application. In the future this SkyHook should also support `websockets` connections 22 | 23 | 24 | 25 | .. toctree:: 26 | :maxdepth: 3 27 | :caption: Contents: 28 | 29 | general 30 | server 31 | server_functionality 32 | server_commands 33 | client 34 | unreal_engine_server 35 | unreal_engine_client 36 | 37 | 38 | 39 | Indices and tables 40 | ================== 41 | 42 | * :ref:`genindex` 43 | * :ref:`modindex` 44 | * :ref:`search` 45 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | skyhook 2 | ======= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | skyhook 8 | -------------------------------------------------------------------------------- /docs/source/nisse.md: -------------------------------------------------------------------------------- 1 | # SkyHook 2 | 3 | SkyHook was created to facilitate communication between DCCs, standalone applications, web browsers and game engines. As of right now, it's working in Houdini, Blender, Maya and Unreal Engine. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | It works both with Python 2.7.x and 3.x and you don't need to have the same Python version across programs. For example, you can build a standalone application in Python 3.8.5 and use SkyHook to communicate with Maya's Python 2.7. This makes it much easier to use than something like RPyC where even a minor version difference can stop it from working. 15 | 16 | SkyHook consist of 2 parts that can, but don't have to, work together. There's a client and a server. The server is just a very simple HTTP server that takes JSON requests. It parses those requests and tries to call the function that was passed in the request. It returns another JSON dictionary with the result of the outcome of the function. 17 | 18 | The client just makes a a POST request to the server with a JSON payload. This is why you can basically use anything that's able to do so as a client. Could be in a language other than Python, or even just webpage or mobile application. In the future this SkyHook should also support `websockets` connections 19 | -------------------------------------------------------------------------------- /docs/source/server.rst: -------------------------------------------------------------------------------- 1 | SkyHook Server 2 | =============== 3 | 4 | The server is built on Python's ``wsgiref.simple_server`` and PyQt5/PySide2. Once it's started, it enters an infinite loop where it continues to take requests until its ``__keep_running`` boolean is set to False. 5 | Depending on where you're using the server, you might use different ways to start it. Also, every host program has its own predefined port to which the server binds itself. This can be overridden if you want (``Server.port``) This could potentially be useful if you have both a GUI and a headless version of a program running. 6 | 7 | 8 | Maya and Houdini 9 | ----------------- 10 | 11 | Maya and Houdini will happily execute any Python code in a different thread without any (added) stability issues (please link to this page when you prove me wrong :)). As such, we can launch the server by making a SkyHook server object, making a QThread object, linking the two together and starting the ``run`` function on the thread: 12 | 13 | .. code-block:: python 14 | 15 | from skyhook.server import Server 16 | skyhook_server = Server(host_program="maya", load_modules=["maya"]) 17 | thread_object = QThread() 18 | skyhook_server.is_terminated.connect(partial(__kill_thread, thread_object)) 19 | skyhook_server.moveToThread(thread_object) 20 | 21 | thread_object.started.connect(skyhook_server.start_listening) 22 | thread_object.start() 23 | 24 | Or, you can use the ``start_server_in_thread`` function from ``skyhook.server``: 25 | 26 | .. code-block:: python 27 | 28 | import skyhook.server as shs 29 | from skyhook.constants import HostPrograms 30 | thread, server = shs.start_server_in_thread(host_program=HostPrograms.houdini, load_modules=["houdini"]) 31 | 32 | It returns the ``QThread`` and the ``Server`` object. 33 | 34 | Blender 35 | -------------- 36 | Blender is a bit finicky when it comes to executing anything from ``bpy.ops`` in a thread different than the main program thread. Because of this, we have to make an object that gets instructions from the SkyHook server whenever the server handles a request. This object is called a ``MainThreadExecutor`` and lives in the ``skyhook.server`` module. While the SkyHook server runs in a separate thread, the ``MainThreadExecutor`` (as the name suggests) runs in Blender's main thread. We pass information from the server to the main thread so it can safely be executed there. 37 | 38 | Adding functionality to the ``blender`` module also needs some extra care. 39 | 40 | You can run construct the server like this: 41 | 42 | .. code-block:: python 43 | 44 | import skyhook.server as shs 45 | from skyhook.constants import HostPrograms 46 | 47 | skyhook_server = Server(host_program=HostPrograms.blender, load_modules=["blender"], use_main_thread_executor=True) 48 | executor = MainThreadExecutor(skyhook_server) 49 | skyhook_server.exec_command_signal.connect(executor.execute) 50 | 51 | thread_object = QThread() 52 | skyhook_server.moveToThread(thread_object) 53 | thread_object.started.connect(skyhook_server.start_listening) 54 | thread_object.start() 55 | 56 | Or, use this one-liner: 57 | 58 | .. code-block:: python 59 | 60 | import skyhook.server as shs 61 | from skyhook.constants import HostPrograms 62 | thread, executor, server = shs.start_executor_server_in_thread(host_program=HostPrograms.blender, load_modules=["blender"]) 63 | 64 | Blender as a whole is a bit weirder when it comes to running a Qt Application and needs some boiler plate code to do so. I found that the following code is able to run the server without much problems: 65 | 66 | .. code-block:: python 67 | 68 | # Clear the default system console 69 | print("\n" * 60) 70 | 71 | import bpy 72 | 73 | import sys 74 | import subprocess 75 | import pkg_resources 76 | 77 | import traceback 78 | 79 | # Blender needs these packages to run skyhook 80 | required = {"PySide2", "requests", "git+https://github.com/EmbarkStudios/skyhook"} 81 | 82 | # Let's find the missing packages 83 | installed = {pkg.key for pkg in pkg_resources.working_set} 84 | missing = required - installed 85 | 86 | # and if there are any, pip install them 87 | if missing: 88 | python = sys.executable 89 | subprocess.check_call([python, "-m", "pip", "install", *missing], stdout=subprocess.DEVNULL) 90 | 91 | try: 92 | from PySide2.QtCore import * 93 | from PySide2.QtWidgets import * 94 | from PySide2.QtGui import * 95 | except Exception: 96 | print("Couldn't install PySide2") 97 | 98 | class SkyhookServer(): 99 | def __init__(self): 100 | self.thread, self.executor, self.server = \ 101 | server.start_executor_server_in_thread(host_program=HostPrograms.blender, 102 | load_modules=[blender]) 103 | self.__app = QApplication.instance() 104 | if not self.__app: 105 | self.__app = QApplication(["blender"]) 106 | if self.__app: 107 | self.__app.processEvents() 108 | 109 | if __name__ == "__main__": 110 | try: 111 | import skyhook 112 | from skyhook import HostPrograms 113 | from skyhook.modules import blender 114 | from skyhook import server 115 | 116 | bpy.types.Scene.skyhook_server = SkyhookServer() 117 | 118 | except Exception as err: 119 | print(str(traceback.format_exc())) 120 | print(str(err)) 121 | 122 | 123 | Standalone 124 | -------------- 125 | 126 | SkyHook doesn't necessarily need to run inside a host program. It can be a stand alone application as well that you can dump on machine somewhere to control. In this case, you'll most likely won't have to worry about running it in a separate thread. You can start it like this, just from a Python prompt: 127 | 128 | .. code-block:: python 129 | 130 | import skyhook.server as shs 131 | from skyhook.constants import HostPrograms 132 | 133 | skyhook_server = Server() 134 | skyhook_server.start_listening() 135 | 136 | Since we're not supplying it with a host program, it will bind itself to ``skyhook.constants.Ports.undefined`` (65500). Unless you add the ``load_modules`` argument when you make the ``Server``, the functionality will come from ``skyhook.modules.core``. 137 | 138 | The one-liner: 139 | 140 | .. code-block:: python 141 | 142 | import skyhook.server as shs 143 | shs.start_blocking_server() 144 | 145 | 146 | I'm actually using it to connect to a Raspberry Pi that controls my Christmas lights 😄 🎄 147 | 148 | -------------------------------------------------------------------------------- /docs/source/server_commands.rst: -------------------------------------------------------------------------------- 1 | Server commands 2 | ================ 3 | 4 | The SkyHook server comes with a couple of commands that are not executed from a module, but on the server itself. These are described in 5 | ``skyhook.constants.ServerCommands``. They always start with "SKY", so don't add any functions with names like this to any module you load into the server. 6 | 7 | .. code-block:: python 8 | 9 | class ServerCommands: 10 | SKY_SHUTDOWN = "SKY_SHUTDOWN" 11 | SKY_LS = "SKY_LS" 12 | SKY_RELOAD_MODULES = "SKY_RELOAD_MODULES" 13 | SKY_HOTLOAD = "SKY_HOTLOAD" 14 | SKY_UNLOAD = "SKY_UNLOAD" 15 | SKY_FUNCTION_HELP = "SKY_FUNCTION_HELP" 16 | 17 | * SKY_SHUTDOWN: Shuts down the server 18 | * SKY_LS: Returns a list with all the function names this server can execute 19 | * SKY_RELOAD_MODULES: Remotely reloads the loaded modules 20 | * SKY_HOTLOAD: needs to be called with the ``skyhook.Constants.module`` argument as a list of module names in the ``parameters``, as explained above. 21 | * SKY_UNLOAD: needs to be called with the ``skyhook.Constants.module`` argument as a list of module names in the ``parameters``, these modules will be removed from the server. 22 | * SKY_FUNCTION_HELP: needs to be called with "function_name" argument as string in the ``parameters``, this will return the names of the arguments, packed arguments and packed keywords a function requires. 23 | 24 | 25 | Their functionality is hardcoded in ``__process_server_command()`` in the ``Server`` class. If you want to add any extra functionality, this is where you do it. -------------------------------------------------------------------------------- /docs/source/server_functionality.rst: -------------------------------------------------------------------------------- 1 | Server functionality 2 | ===================== 3 | 4 | 5 | The server itself doesn't really do much. Any functionality needs to come from a SkyHook module that either gets loaded when the server is started or hotloaded when the server is running. Modules should be split between different host programs and should ideally be placed in the ``skyhook.modules`` folder. 6 | 7 | * You can load these modules either by name (as a ``string``) or as a Python ``module`` object. 8 | * You can load them when constructing the ``Server`` object, hotload them using the ``Server`` object or remotely using the ``SKY_HOTLOAD`` server command. 9 | 10 | To load the modules when constructing the server, you just pass a list to the ``load_modules`` argument. The following example shows how to load the ``houdini`` module as a string and the ``maya`` module as a ``module`` object. These can be mixed if you want to. 11 | 12 | .. code-block:: python 13 | 14 | import skyhook.server 15 | # import the maya module to then pass into the load_modules argument 16 | import skyhook.modules.maya as maya_module 17 | from skyhook import HostPrograms 18 | 19 | thread, server = skyhook.server.start_server_in_thread(host_program=HostPrograms.houdini, load_modules=["houdini", maya_module]) 20 | 21 | If you don't want to restart the server and have access to the ``Server`` object, you can invoke the ``hotload_module()`` function to add a module: 22 | 23 | .. code-block:: python 24 | 25 | import skyhook.server 26 | import skyhook.modules.maya as maya_module 27 | from skyhook import HostPrograms 28 | 29 | thread, server = skyhook.server.start_server_in_thread(host_program=HostPrograms.houdini) 30 | # server is running, now hotload some modules 31 | server.hotload_module("houdini") 32 | server.hotload_module(maya_module) 33 | 34 | 35 | If you don't have access to the ``Server`` object, you can also use the server command ``SKY_HOTLOAD`` to remotely tell the server to add a new module. An example coud look like this: 36 | 37 | .. code-block:: python 38 | 39 | import skyhook.client 40 | from skyhook import ServerCommands 41 | 42 | maya_client = skyhook.client.MayaClient() 43 | maya_client.execute(ServerCommands.SKY_HOTLOAD, parameters={"modules": ["maya"]}) 44 | 45 | 46 | Adding functionality to SkyHook from elsewhere 47 | ------------------------------------------------- 48 | The examples above all assume that whatever module you want to load is located in the ``skyhook.modules`` folder. It is possible to load modules from elsewhere if you so fancy. This is done using by setting the ``is_skyhook_module`` to ``False`` in the ``hotload_module()`` function on ``Server``. 49 | 50 | Example: 51 | 52 | .. code-block:: python 53 | 54 | import skyhook.server 55 | import skyhook.modules.maya as maya_module 56 | import embark_tools.Core.houdini_utils as houdini_utils 57 | from skyhooks import HostPrograms 58 | 59 | thread, server = skyhook.server.start_server_in_thread(host_program=HostPrograms.houdini) 60 | server.hotload_module("embark_tools.Core.maya_utils", is_skyhook_module=False) 61 | server.hotload_module(houdini_utils, is_skyhook_module=False) 62 | 63 | 64 | .. warning:: 65 | 66 | Anything sent to the server remotely is in JSON form. Meaning that if it can't be serialized, the SkyHook server won't know what to do with it. So doing something like this will fail: 67 | 68 | .. code-block:: python 69 | 70 | import skyhook.client 71 | import skyhook.modules.maya as maya_module 72 | from skyhookimport ServerCommands 73 | 74 | maya_client = skyhook.client.MayaClient() 75 | # send the module as a Python module instead of a string 76 | maya_client.execute(ServerCommands.SKY_HOTLOAD, parameters={"modules": [maya_module], "is_skyhook_module": False}) 77 | 78 | 79 | Function naming clashes 80 | ------------------------ 81 | 82 | By default, the server will look through all its loaded modules to find the function that's being called by the client. This means that if you have modules that both have a function with the same name, the server might execute the function from the wrong module. You can set ``module_name`` in the function ``get_function_by_name`` to specify a specific module to search in. If you want to search in a SkyHook module, just passing the name of the module will work. However, if you've loaded a module from outside SkyHook, you have to provide the complete module path. 83 | 84 | You set this module name, or module path, by adding ``skyhook.constants.Constands.module`` ("_Module") to the parameters of your request. 85 | 86 | .. note:: 87 | 88 | This might not be working correctly 100% of the time. For now, it might be best to avoid having the same function name in different modules. You're a creative person, think of a better, COOLER function name than the one that already exists! -------------------------------------------------------------------------------- /docs/source/skyhook.modules.rst: -------------------------------------------------------------------------------- 1 | skyhook.modules package 2 | ======================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | skyhook.modules.blender module 8 | ------------------------------ 9 | 10 | .. automodule:: skyhook.modules.blender 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | skyhook.modules.core module 16 | --------------------------- 17 | 18 | .. automodule:: skyhook.modules.core 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | skyhook.modules.houdini module 24 | ------------------------------ 25 | 26 | .. automodule:: skyhook.modules.houdini 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | skyhook.modules.maya\_mod module 32 | -------------------------------- 33 | 34 | .. automodule:: skyhook.modules.maya_mod 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | skyhook.modules.unreal module 40 | ----------------------------- 41 | 42 | .. automodule:: skyhook.modules.unreal 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | Module contents 48 | --------------- 49 | 50 | .. automodule:: skyhook.modules 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | -------------------------------------------------------------------------------- /docs/source/skyhook.rst: -------------------------------------------------------------------------------- 1 | skyhook package 2 | =============== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | skyhook.modules 11 | 12 | Submodules 13 | ---------- 14 | 15 | skyhook.client module 16 | --------------------- 17 | 18 | .. automodule:: skyhook.client 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | skyhook.constants module 24 | ------------------------ 25 | 26 | .. automodule:: skyhook.constants 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | skyhook.logger module 32 | --------------------- 33 | 34 | .. automodule:: skyhook.logger 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | skyhook.server module 40 | --------------------- 41 | 42 | .. automodule:: skyhook.server 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | Module contents 48 | --------------- 49 | 50 | .. automodule:: skyhook 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | -------------------------------------------------------------------------------- /docs/source/unreal_engine_server.rst: -------------------------------------------------------------------------------- 1 | SkyHook server in Unreal 2 | ========================= 3 | 4 | .. warning:: 5 | Only supporting Unreal Engine 4.26 and up! 6 | 7 | 8 | Unreal is a bit of a different beast. It does support Python for editor related tasks, but seems to be starving any threads pretty quickly. That's why it's pretty much impossible to run the SkyHook server in Unreal like we're able to do so in other programs. However, as explained in the main outline, SkyHook clients don't have to necessarily connect to SkyHook servers. That's why we can use ``skyhook.client.UnrealClient`` with Unreal Engine's built-in Web Remote Control. 9 | 10 | Web Remote Control is a plugin you have first have to load before you can use it. It's still very much in beta right now and changing any functionality is not easy. After you've loaded the plugin, start it by typing ``WebControl.StartServer`` in the default ``Cmd`` console in the Output Log window. Or, if you want to always start with the project, enter ``WebControl.EnableServerOnStartup 1``. 11 | 12 | Loading a SkyHook module in Unreal is done by just importing it like normal. I recommend putting the code for the SkyHook module in a file called "skyhook" in the Python folder of your project (/Game/Content/Python). If it is, you can just do the following to load it: 13 | 14 | .. code-block:: python 15 | 16 | import skyhook 17 | 18 | 19 | If you want to load this automatically when your start your project, put the import statement in a file called ``init_unreal.py`` inside your Python folder. 20 | 21 | .. note:: 22 | 23 | When importing a Python module, Unreal (Python) creates a ``__pycache__`` folder in the same location you're loading your module from. In some cases leaving this folder in place can cause a crash when you close your editor. You can add the following couple of lines to your ``init_unreal.py`` file to clean up the pycache folder after importing. 24 | 25 | .. code-block:: python 26 | 27 | import os 28 | 29 | pycache_folder = os.path.join(os.path.dirname(__file__), "__pycache__") 30 | 31 | if os.path.isdir(pycache_folder): 32 | shutil.rmtree(pycache_folder) 33 | 34 | 35 | The SkyHook Unreal module 36 | -------------------------- 37 | 38 | Unreal has specific requirements to run Python code. So you have to keep that in mind when adding functionality to the SkyHook module. In order for it to be "seen" in Unreal you need to decorate your class and functions with specific Unreal decorators. 39 | 40 | Decorating the class with ``unreal.uclass()``: 41 | 42 | .. code-block:: python 43 | 44 | import unreal 45 | 46 | @unreal.uclass() 47 | class SkyHookCommands(unreal.BlueprintFunctionLibrary): 48 | pass 49 | 50 | 51 | Epic recommends marking all your functions as ``static``: 52 | 53 | .. code-block:: python 54 | 55 | @unreal.ufunction(static=True) 56 | def say_hello(): 57 | unreal.log_warning("Hello!") 58 | 59 | 60 | 61 | If you want your function to accept parameters, you need to add them in the decorator like this 62 | 63 | .. code-block:: python 64 | 65 | import os 66 | 67 | @unreal.ufunction(params=[str, str], static=True) 68 | def rename_asset(asset_path, new_name): 69 | dirname = os.path.dirname(asset_path) 70 | new_name = dirname + "/" + new_name 71 | unreal.EditorAssetLibrary.rename_asset(asset_path, new_name) 72 | unreal.log_warning("Renamed to %s" % new_name) 73 | 74 | 75 | .. note:: 76 | You can not use Python's ``list`` in the decorator for the Unreal functions. Use ``unreal.Array(type)``, eg: ``unreal.Array(float)``. 77 | 78 | .. note:: 79 | You can not use Python's ``dict`` in the decorator for the Unreal functions. Use ``unreal.Map(key type, value type)``, eg: ``unreal.Map(str, int)`` 80 | 81 | Add the return type in your decorator like this: 82 | 83 | .. code-block:: python 84 | 85 | @unreal.ufunction(params=[int], ret=unreal.Map(str, int), static=True) 86 | def get_dictionary(health_multiplier): 87 | return_dict = { 88 | "health": 20 * health_multiplier, 89 | "damage": 90 90 | } 91 | 92 | return return_dict 93 | 94 | 95 | Returning segues us into a couple of things to keep in mind. 96 | 97 | 98 | 99 | Things to keep in mind 100 | ---------------------- 101 | 102 | .. warning:: 103 | In 4.26 and 4.26.1 there's a bug that causes your editor to crash if you're returning a non-empty ``unreal.Array``` over Web Remote Control from Python. It's been reported and hopefully will get fixed soon. 104 | 105 | 106 | This is an example function that will crash UE. 107 | 108 | .. code-block:: python 109 | 110 | @unreal.ufunction(ret=unreal.Array(float), static=True) 111 | def get_list_float(): 112 | return_list = [1.2, 55.9, 65.32] 113 | return return_list 114 | 115 | A way to get around this is to return a string version of the list, instead of the actual list. Since the return object that's being sent back to the client needs to be a JSON, you can't be returning any "exotic" objects in a list anyway. 116 | 117 | This function WON'T crash your editor: 118 | 119 | .. code-block:: python 120 | 121 | @unreal.ufunction(ret=str, static=True) 122 | def get_list_float(): 123 | return_list = [1.2, 55.9, 65.32] 124 | 125 | #stringify it! 126 | return str(return_list) 127 | 128 | When the return value is sent back to the client, the client will first try to ``eval`` it to see if it contains a Python object. This happens in ``skyhook.client.UnrealClient.execute```. While this was initially added as a bit of a hack to just get it working, it actually soon became useful in another way. 129 | 130 | Consider the following function. We want to return a dictionary where the values are lists of integers: 131 | 132 | .. code-block:: python 133 | 134 | @unreal.ufunction(ret=unreal.Map(str, int), static=True) 135 | def get_dictionary_with_lists(): 136 | return_dict = { 137 | "checkpoints": [4, 18, 25], 138 | "num_enemies": [3, 9, 12] 139 | } 140 | 141 | return return_dict 142 | 143 | Unreal will throw the following error: 144 | 145 | .. warning:: 146 | 147 | `TypeError: Map: 'value' (Array (IntProperty)) cannot be a container element type (directly nested containers are not supported - consider using an intermediary struct instead)` 148 | 149 | However, when the ``return_dict`` is first turned into a string before returning it (be sure to also set your return type to string, ``ret=str``) everything works just fine. Your client on the receiving end will turn it into a Python `dict` again. 150 | -------------------------------------------------------------------------------- /examples/blender_client.py: -------------------------------------------------------------------------------- 1 | from skyhook.client import BlenderClient 2 | from skyhook.modules import blender 3 | from skyhook.modules import core 4 | from skyhook import ServerCommands 5 | 6 | # Make the client 7 | client = BlenderClient() 8 | 9 | # Let's make a cube using the blender module 10 | client.execute(blender.make_cube) 11 | 12 | # Let's print something using the default core module 13 | client.execute(core.echo_message, parameters={"message": "Hi there, Blender!"}) 14 | 15 | # Let's shut the server down 16 | client.execute(ServerCommands.SKY_SHUTDOWN) -------------------------------------------------------------------------------- /examples/server_in_blender.py: -------------------------------------------------------------------------------- 1 | # Clear the default system console 2 | print("\n" * 60) 3 | 4 | import bpy 5 | 6 | import sys 7 | import subprocess 8 | import pkg_resources 9 | 10 | import traceback 11 | 12 | # Blender needs these packages to run skyhook 13 | required = {"PySide2", "requests", "git+https://github.com/EmbarkStudios/skyhook"} 14 | 15 | # Let's fine the missing packages 16 | installed = {pkg.key for pkg in pkg_resources.working_set} 17 | missing = required - installed 18 | 19 | # and if there are any, pip install them 20 | if missing: 21 | python = sys.executable 22 | subprocess.check_call([python, "-m", "pip", "install", *missing], stdout=subprocess.DEVNULL) 23 | 24 | try: 25 | from PySide2.QtCore import * 26 | from PySide2.QtWidgets import * 27 | from PySide2.QtGui import * 28 | except Exception: 29 | print("Couldn't install PySide2") 30 | 31 | class SkyhookServer(): 32 | def __init__(self): 33 | self.thread, self.executor, self.server = \ 34 | server.start_executor_server_in_thread(host_program=HostPrograms.blender, 35 | load_modules=[blender]) 36 | self.__app = QApplication.instance() 37 | if not self.__app: 38 | self.__app = QApplication(["blender"]) 39 | if self.__app: 40 | self.__app.processEvents() 41 | 42 | if __name__ == "__main__": 43 | try: 44 | import skyhook 45 | from skyhook import HostPrograms 46 | from skyhook.modules import blender 47 | from skyhook import server 48 | 49 | bpy.types.Scene.skyhook_server = SkyhookServer() 50 | 51 | except Exception as err: 52 | print(str(traceback.format_exc())) 53 | print(str(err)) 54 | -------------------------------------------------------------------------------- /icon/sky_hook_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/icon/sky_hook_logo.png -------------------------------------------------------------------------------- /icon/sky_hook_logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/icon/sky_hook_logo.psd -------------------------------------------------------------------------------- /package.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | name = 'skyhook' 4 | 5 | version = '0.1.0' 6 | 7 | requires = [ 8 | "platform-windows", 9 | "arch-AMD64", 10 | ] 11 | 12 | 13 | build_command = "python {root}/package.py {install}" 14 | 15 | def commands(): 16 | import os 17 | # Path to cmake.exe and cmake-gui.exe 18 | env.PYTHONPATH.append(os.path.join("{root}", "python")) 19 | 20 | 21 | def build(*args): 22 | import os 23 | import shutil 24 | 25 | # build config 26 | is_build = os.getenv("REZ_BUILD_ENV") 27 | build_path = os.getenv("REZ_BUILD_PATH") 28 | is_install = os.getenv("REZ_BUILD_INSTALL") 29 | install_path = os.getenv("REZ_BUILD_INSTALL_PATH") 30 | source_path = os.getenv("REZ_BUILD_SOURCE_PATH") 31 | is_install = os.getenv("REZ_BUILD_INSTALL") 32 | 33 | if is_install: 34 | import glob 35 | src = os.path.join(source_path, "skyhook") 36 | dest = os.path.join(install_path, "python", "skyhook") 37 | shutil.rmtree(dest, ignore_errors=True) 38 | shutil.copytree(src, dest, dirs_exist_ok=True) 39 | 40 | 41 | if __name__ == "__main__": 42 | import sys 43 | build(*sys.argv) 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | # any data files that match this pattern will be included 7 | data_files_to_include = ["*.png"] 8 | 9 | setuptools.setup( 10 | name="skyhook", 11 | version="3.0.0", 12 | author="Niels Vaes", 13 | author_email="nielsvaes@gmail.com", 14 | description="Engine and DCC communication system", 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | url="https://github.com/EmbarkStudios/skyhook", 18 | install_requires=['requests'], 19 | packages=setuptools.find_packages(), 20 | package_data={ 21 | "": data_files_to_include, 22 | }, 23 | python_requires='>=3.6', 24 | classifiers=[ 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.6", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: 3.13", 35 | "License :: OSI Approved :: MIT License", 36 | "Development Status :: 5 - Production/Stable", 37 | ], 38 | license="MIT", 39 | keywords='engine dcc communication system', 40 | project_urls={ 41 | 'Homepage': 'https://github.com/EmbarkStudios/skyhook', 42 | 'Documentation': 'https://github.com/EmbarkStudios/skyhook/wiki', 43 | 'Issue Tracker': 'https://github.com/EmbarkStudios/skyhook/issues', 44 | }, 45 | ) -------------------------------------------------------------------------------- /skyhook/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import Logger 2 | logger = Logger() 3 | 4 | from . import modules 5 | from . import constants 6 | from . import client 7 | try: 8 | from . import server 9 | except: 10 | logger.warning("Server import failed, so it won't be available in this instance") 11 | 12 | from .constants import * -------------------------------------------------------------------------------- /skyhook/client.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | from datetime import datetime 3 | 4 | from .constants import HostPrograms, Results, Ports, ServerCommands, Errors 5 | import requests 6 | 7 | try: 8 | import websockets 9 | import asyncio 10 | except: 11 | pass 12 | # print("failed to import websockets/asyncio") 13 | 14 | class Client(object): 15 | """ 16 | Base client from which all other Clients will inherit 17 | """ 18 | def __init__(self, port=Ports.undefined, host_address="127.0.0.1"): 19 | super(Client, self).__init__() 20 | 21 | self.host_address = host_address 22 | self.port = port 23 | 24 | self.__timeout = 1000 25 | self.__echo_execution = True 26 | self.__echo_payload = True 27 | self._is_executing = False 28 | 29 | def set_timeout(self, value): 30 | """ 31 | If set to anything bigger than 0, will time out the connection to the server after this value in seconds 32 | 33 | :param value: *float* 34 | :return: None 35 | """ 36 | self.__timeout = value 37 | 38 | def timeout(self): 39 | """ 40 | Return the current timeout value 41 | 42 | :return: *float* 43 | """ 44 | return self.__timeout 45 | 46 | def set_echo_execution(self, value): 47 | """ 48 | If set to true, the client will print out the response coming from the server 49 | 50 | :param value: *bool* 51 | :return: None 52 | """ 53 | self.__echo_execution = value 54 | 55 | def echo_execution(self): 56 | """ 57 | Return True or False whether or not responses from the server are printed in the client 58 | 59 | :return: *bool* 60 | """ 61 | return self.__echo_execution 62 | 63 | def set_echo_payload(self, value): 64 | """ 65 | If set to true, the client will print the JSON payload it's sending to the server 66 | 67 | :param value: *bool* 68 | :return: 69 | """ 70 | self.__echo_payload = value 71 | 72 | def echo_payload(self): 73 | """ 74 | Return True or False whether or not the JSON payloads sent to the server are printed in the client 75 | 76 | :return: 77 | """ 78 | return self.__echo_payload 79 | 80 | def is_executing(self): 81 | """ 82 | Retrun True or False if the client is still executing a command 83 | 84 | :return: *bool* 85 | """ 86 | return self._is_executing 87 | 88 | def is_host_online(self): 89 | """ 90 | Convenience function to call on the client. "is_online" comes from the core module 91 | 92 | :return: *bool* 93 | """ 94 | try: 95 | response = self.execute("is_online", {}) 96 | return response.get(Results.return_value) is True 97 | except Errors.SkyHookCantReachServer: 98 | return False 99 | 100 | 101 | def execute(self, command, parameters={}, timeout=0): 102 | """ 103 | Executes a given command for this client. The server will look for this command in the modules it has loaded 104 | 105 | :param command: *string* or *function* The command name or the actual function object that you can import from the modules module 106 | :param parameters: *dict* of the parameters (arguments) for the the command. These have to match the argument names on the function in the module exactly 107 | :param timeout: *float* time in seconds after which the request will timeout. If not set here, self.time_out will be used (1.5 by default) 108 | :return: *dict* of the response coming from the server 109 | 110 | From a SkyHook server it looks like: 111 | { 112 | 'ReturnValue': ['Camera', 'Cube', 'Cube.001', 'Light'], 113 | 'Success': True, 114 | 'Time': '09:43:18' 115 | } 116 | 117 | 118 | From Unreal it looks like this: 119 | { 120 | 'ReturnValue': ['/Game/Developers/cooldeveloper/Maps/ScatterDemoLevel/ScatterDemoLevelMaster.ScatterDemoLevelMaster', 121 | '/Game/Apple/Core/UI/Widgets/WBP_CriticalHealthLevelVignette.WBP_CriticalHealthLevelVignette', 122 | '/Game/Apple/Lighting/LUTs/RGBTable16x1_Level_01.RGBTable16x1_Level_01'] 123 | } 124 | """ 125 | self._is_executing = True 126 | if timeout <= 0: 127 | timeout = self.__timeout 128 | 129 | if callable(command): 130 | command = command.__name__ 131 | 132 | url = "http://%s:%s" % (self.host_address, self.port) 133 | payload = self.__create_payload(command, parameters) 134 | 135 | try: 136 | response = requests.post(url, json=payload, timeout=timeout).json() 137 | except requests.exceptions.ConnectionError as err: 138 | raise Errors.SkyHookCantReachServer(f"Can't reach server {self.host_address} on port {self.port}") 139 | 140 | if self.echo_payload(): 141 | pprint.pprint(payload) 142 | 143 | if self.echo_execution(): 144 | pprint.pprint(response) 145 | 146 | self._is_executing = False 147 | 148 | return response 149 | 150 | def __create_payload(self, command, parameters): 151 | """ 152 | Constructs the dictionary for the JSON payload that will be sent to the server 153 | 154 | :param command: *string* name of the command 155 | :param parameters: *dictionary* 156 | :return: *dictionary* 157 | """ 158 | payload = { 159 | "FunctionName": command, 160 | "Parameters": parameters 161 | } 162 | 163 | return payload 164 | 165 | class SubstancePainterClient(Client): 166 | """ 167 | Custom client for Substance Painter 168 | """ 169 | def __init__(self, port=Ports.substance_painter, host_address="127.0.0.1"): 170 | super().__init__(port=port, host_address=host_address) 171 | self.host_program = HostPrograms.substance_painter 172 | 173 | 174 | class BlenderClient(Client): 175 | """ 176 | Custom client for Blender 177 | """ 178 | def __init__(self, port=Ports.blender, host_address="127.0.0.1"): 179 | super().__init__(port=port, host_address=host_address) 180 | self.host_program = HostPrograms.blender 181 | 182 | 183 | class MayaClient(Client): 184 | """ 185 | Custom client for Maya 186 | """ 187 | def __init__(self, port=Ports.maya, host_address="127.0.0.1"): 188 | super().__init__(port=port, host_address=host_address) 189 | self.host_program = HostPrograms.maya 190 | 191 | 192 | class HoudiniClient(Client): 193 | """ 194 | Custom client for Houdini 195 | """ 196 | def __init__(self, port=Ports.houdini, host_address="127.0.0.1"): 197 | super().__init__(port=port, host_address=host_address) 198 | self.host_program = HostPrograms.houdini 199 | 200 | 201 | class UnrealClient(Client): 202 | """ 203 | Custom client for Unreal. This overwrites most of the basic functionality because we can't run a Skyhook server 204 | in Unreal, but have to rely on Web Remote Control. 205 | 206 | There is a file called skyhook in /Game/Python/ that holds the SkyHook classes to be used with this client. 207 | This file has to be loaded by running "import skyhook" in Unreal's Python editor, or imported when the project 208 | is loaded. 209 | 210 | """ 211 | 212 | def __init__(self, port=Ports.unreal, host_address="127.0.0.1"): 213 | super().__init__(port=port, host_address=host_address) 214 | self.host_program = HostPrograms.unreal 215 | self.__command_object_path = "/Engine/PythonTypes.Default__SkyHookCommands" 216 | self.__server_command_object_path = "/Engine/PythonTypes.Default__SkyHookServerCommands" 217 | self.__headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} 218 | 219 | def execute(self, command, parameters={}, timeout=0, function=True, property=False): 220 | """ 221 | Will execute the command so Web Remote Control understands it. 222 | 223 | :param command: *string* command name 224 | :param parameters: *dict* of the parameters (arguments) for the the command. These have to match the argument names on the function in the module exactly 225 | :param timeout: *float* time in seconds after which the request will timeout. If not set here, self.time_out will be used (1.5 by default) 226 | :param function: *bool* ignore, not used 227 | :param property: *bool* ignore, not used 228 | :return: *dict* of the response coming from Web Remote Control 229 | """ 230 | self._is_executing = True 231 | if timeout <= 0: 232 | timeout = self.timeout() 233 | 234 | url = "http://%s:%s/remote/object/call" % (self.host_address, self.port) 235 | 236 | if command in dir(ServerCommands): 237 | payload = self.__create_payload(command, parameters, self.__server_command_object_path) 238 | used_object_path = self.__server_command_object_path 239 | else: 240 | payload = self.__create_payload(command, parameters, self.__command_object_path) 241 | used_object_path = self.__command_object_path 242 | 243 | try: 244 | response = requests.put(url, json=payload, headers=self.__headers, timeout=timeout).json() 245 | except requests.exceptions.ConnectionError: 246 | raise(Errors.SkyHookCantReachServer("Can't connect to Unreal, is Unreal running and the Remote Control API plugin loaded?")) 247 | 248 | try: 249 | # UE 4.26: Returning an unreal.Array() that's not empty crashes the editor 250 | # (https://udn.unrealengine.com/s/question/0D52L00005KOA24SAH/426-crashes-when-trying-to-return-an-unrealarray-over-web-remote-control-from-python) 251 | # The (ugly) workaround for this is to return the list as a string eg "['/Game/Content/file', '/Game/Content/file2']" 252 | # Then we try to eval the string here, if it succeeds, we'll have the list. 253 | 254 | # However! 255 | # This actually serves another purpose. It's much easier to return a dictionary from Unreal as a string compared 256 | # to an unreal.Map. When returning the stringified dict, you don't need to explicitly tell Unreal what type 257 | # the keys and values are. It also gets around errors like the following: 258 | # 259 | # ------------------------------------------------------------------------------------------------------- 260 | # `TypeError: Map: 'value' (Array (IntProperty)) cannot be a container element type (directly nested 261 | # containers are not supported - consider using an intermediary struct instead)` 262 | # ------------------------------------------------------------------------------------------------------- 263 | # 264 | # You'd get this error in Uneal when you try to declare the return type of a function as `unreal.Map(str, unreal.Array(int)` 265 | # When you'd want the value of the keys to be a list of ints, for example. 266 | 267 | # On top of that, stringifying a dict also just lets you return a bunch of values that are not all the same type. 268 | # As long as it can get serialized into a JSON object, it will come out this end as a proper dictionary. 269 | evalled_return_value = eval(response.get(Results.return_value)) 270 | response = {Results.return_value: evalled_return_value} 271 | except: 272 | pass 273 | 274 | if self.echo_payload(): 275 | pprint.pprint(payload) 276 | 277 | if self.echo_execution(): 278 | pprint.pprint(response) 279 | 280 | self._is_executing = False 281 | 282 | return response 283 | 284 | def set_command_object_path(self, path="/Engine/PythonTypes.Default__PythonClassName"): 285 | """ 286 | Set the object path for commands 287 | 288 | :param path: *string* path. For Python functions, this has to be something like /Engine/PythonTypes.Default__ 289 | You need to add the leading 'Default__', this is what Unreal Engine expects 290 | :return: None 291 | """ 292 | self.__command_object_path = path 293 | 294 | def command_object_path(self): 295 | """ 296 | Gets the command object path 297 | 298 | :return: *string* Object path 299 | """ 300 | return self.__command_object_path 301 | 302 | def __create_payload(self, command, parameters, object_path, echo_payload=True): 303 | payload = { 304 | "ObjectPath": object_path, 305 | "FunctionName": command, 306 | "Parameters": parameters, 307 | "GenerateTransaction": True 308 | } 309 | 310 | return payload 311 | 312 | # class WebsocketClient(Client): 313 | # def __init__(self): 314 | # super(WebsocketClient, self).__init__() 315 | # self.uri = "ws://%s:%s" % (self.host_address, self.port) 316 | # print(self.uri) 317 | # 318 | # def execute(self, command, parameters={}): 319 | # payload = self.__create_payload(command, parameters) 320 | # print(payload) 321 | # 322 | # async def send(): 323 | # async with websockets.connect(self.uri) as websocket: 324 | # await websocket.send(payload) 325 | # response = await websocket.recv() 326 | # return response 327 | # 328 | # response = asyncio.get_event_loop().run_until_complete(send()) 329 | # return response 330 | # 331 | # def __create_payload(self, command, parameters): 332 | # """ 333 | # Constructs the dictionary for the JSON payload that will be sent to the server 334 | # 335 | # :param command: *string* name of the command 336 | # :param parameters: *dictionary* 337 | # :return: *dictionary* 338 | # """ 339 | # payload = { 340 | # "FunctionName": command, 341 | # "Parameters": parameters 342 | # } 343 | # 344 | # return payload 345 | -------------------------------------------------------------------------------- /skyhook/constants.py: -------------------------------------------------------------------------------- 1 | class Constants: 2 | """ 3 | Some very general constants that are used throughout SkyHook 4 | """ 5 | function_name = "FunctionName" 6 | parameters = "Parameters" 7 | module = "_Module" 8 | undefined = "undefined" 9 | 10 | is_skyhook_module = "is_skyhook_module" 11 | 12 | 13 | class Results: 14 | time = "Time" 15 | success = "Success" 16 | return_value = "ReturnValue" 17 | command = "Command" 18 | 19 | 20 | class Ports: 21 | """ 22 | Ports that the host programs use and for the clients to connect to 23 | """ 24 | undefined = 65500 25 | maya = 65501 26 | houdini = 65502 27 | blender = 65504 28 | substance_painter = 65505 29 | unreal = 30010 30 | 31 | 32 | class HostPrograms: 33 | """ 34 | List of host programs 35 | """ 36 | blender = "blender" 37 | unreal = "unreal" 38 | maya = "maya" 39 | houdini = "houdini" 40 | substance_painter = "substance_painter" 41 | 42 | 43 | class ServerCommands: 44 | """ 45 | These are commands that are not run from any modules, but in the server class. In case of Unreal, since we don't 46 | control the server, these commands will have to be overridden in the client. 47 | """ 48 | SKY_SHUTDOWN = "SKY_SHUTDOWN" 49 | SKY_LS = "SKY_LS" 50 | SKY_RELOAD_MODULES = "SKY_RELOAD_MODULES" 51 | SKY_HOTLOAD = "SKY_HOTLOAD" 52 | SKY_UNLOAD = "SKY_UNLOAD" 53 | SKY_FUNCTION_HELP = "SKY_FUNCTION_HELP" 54 | 55 | 56 | class Errors: 57 | """ 58 | Errors when things go wrong 59 | """ 60 | class SkyHookCantReachServer(Exception): 61 | pass 62 | 63 | CALLING_FUNCTION = "ERROR 0x1: An error occurred when calling the function" 64 | IN_FUNCTION = "ERROR 0x2: An error occurred when executing the function" 65 | SERVER_COMMAND = "ERROR 0x3: An error occurred processing this server command" 66 | SERVER_RELOAD = "ERROR 0x4: An error occurred with reloading a module on the server" 67 | TIMEOUT = "ERROR 0x5: The command timed out" 68 | NO_RESPONSE = "ERROR 0x7: No response was generated, is the function on the server returning anything?" 69 | 70 | 71 | class UnrealTypes: 72 | """ 73 | Unreal object types as strings 74 | """ 75 | STATICMESH = "StaticMesh" 76 | SKELETALMESH = "SkeletalMesh" 77 | TEXTURE2D = "Texture2D" 78 | MATERIAL = "Material" 79 | MATERIALINSTANCE = "MaterialInstance" 80 | 81 | 82 | class ServerEvents: 83 | """ 84 | 85 | """ 86 | is_terminated = "is_terminated" 87 | exec_command = "exec_command" 88 | command = "command" 89 | -------------------------------------------------------------------------------- /skyhook/logger.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | 4 | class Status: 5 | SUCCESS = "Success" 6 | WARNING = "Warning" 7 | INFO = "Info" 8 | ERROR = "Error" 9 | DEBUG = "Debug" 10 | 11 | 12 | class Logger(object): 13 | def __init__(self, file_path=None, print_to_screen=True): 14 | super(Logger, self).__init__() 15 | 16 | self.file_path = file_path if file_path is not None else os.path.join(os.path.expanduser("~"), "skyhook.log") 17 | self.__print_to_screen = print_to_screen 18 | 19 | self.debug("\n\n====================== NEW LOG ======================", print_to_screen=False) 20 | 21 | def success(self, message, print_to_screen=True): 22 | self.__add_to_log(Status.SUCCESS, message, print_to_screen) 23 | 24 | def warning(self, message, print_to_screen=True): 25 | self.__add_to_log(Status.WARNING, message, print_to_screen) 26 | 27 | def info(self, message, print_to_screen=True): 28 | self.__add_to_log(Status.INFO, message, print_to_screen) 29 | 30 | def error(self, message, print_to_screen=True): 31 | self.__add_to_log(Status.ERROR, message, print_to_screen) 32 | 33 | def debug(self, message, print_to_screen=True): 34 | self.__add_to_log(Status.DEBUG, message, print_to_screen) 35 | 36 | def __add_to_log(self, status, message, print_to_screen): 37 | time = datetime.datetime.now().strftime("%Y/%m/%d-%H:%M:%S") 38 | line = "SKYHOOK [%s] | %s | %s" % (time, status, message) 39 | 40 | with open(self.file_path, "a") as out_file: 41 | out_file.write(line + "\n") 42 | 43 | if print_to_screen: 44 | print(line) 45 | -------------------------------------------------------------------------------- /skyhook/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/modules/__init__.py -------------------------------------------------------------------------------- /skyhook/modules/blender.py: -------------------------------------------------------------------------------- 1 | """ 2 | Just some examples to help you get on your way with adding in functionality. 3 | 4 | """ 5 | 6 | 7 | try: 8 | import bpy 9 | except: 10 | pass 11 | 12 | def make_cube(): 13 | try: 14 | with bpy.context.temp_override(**__get_window_ctx()): 15 | bpy.ops.mesh.primitive_cube_add(location=(0, 0, 0)) 16 | except Exception as e: 17 | return f"Cube failed: {str(e)}" 18 | 19 | 20 | def make_cube_at_location(location=(0, 0, 0)): 21 | with bpy.context.temp_override(**__get_window_ctx()): 22 | bpy.ops.mesh.primitive_cube_add(location=location) 23 | 24 | 25 | def export_to_usd(filepath="C:/tmp/test.usd", selected_only= True): 26 | """ 27 | Example to export to USD. 28 | """ 29 | try: 30 | bpy.ops.wm.usd_export( 31 | filepath=filepath, 32 | check_existing=False, 33 | selected_objects_only=selected_only, 34 | visible_objects_only=True, 35 | export_materials=False, 36 | generate_preview_surface=False, 37 | export_textures=False, 38 | use_instancing=True, 39 | relative_paths=True, 40 | export_normals=True, 41 | ) 42 | return "Export completed successfully" 43 | except Exception as e: 44 | return f"Export failed: {str(e)}" 45 | 46 | 47 | def get_all_objects_in_scene(): 48 | """ 49 | Gets all the objects in the scene 50 | 51 | :return: *list* with object names 52 | """ 53 | return [obj.name for obj in bpy.data.objects] 54 | 55 | def say_hello(): 56 | """ 57 | Says hello! 58 | 59 | :return: *string* 60 | """ 61 | print("Hello from Blender") 62 | return "I said hello in Blender" 63 | 64 | 65 | def __get_window_ctx(): 66 | """ 67 | Gets a more complete Window Context for blender operations. 68 | """ 69 | window = bpy.context.window_manager.windows[0] 70 | screen = window.screen 71 | 72 | # Find a 3D view area 73 | areas_3d = [area for area in screen.areas if area.type == 'VIEW_3D'] 74 | if areas_3d: 75 | area = areas_3d[0] 76 | region = [region for region in area.regions if region.type == 'WINDOW'][0] 77 | else: 78 | # Fallback to any area 79 | area = screen.areas[0] 80 | region = area.regions[0] 81 | 82 | ctx = { 83 | 'window': window, 84 | 'screen': screen, 85 | 'area': area, 86 | 'region': region, 87 | 'scene': bpy.context.scene, 88 | 'view_layer': bpy.context.view_layer, 89 | } 90 | return ctx -------------------------------------------------------------------------------- /skyhook/modules/core.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from .. import client 3 | 4 | 5 | def is_online(): 6 | """ 7 | Being able to call this function means the server is online and able to handle requests. 8 | 9 | :return: True 10 | """ 11 | return True 12 | 13 | def echo_message(message): 14 | print(message) 15 | return "I printed: %s" % message 16 | 17 | 18 | def run_python_script(script_path): 19 | import runpy 20 | runpy.run_path(script_path, init_globals=globals(), run_name="__main__") 21 | -------------------------------------------------------------------------------- /skyhook/modules/houdini.py: -------------------------------------------------------------------------------- 1 | try: 2 | import hou 3 | except: 4 | pass 5 | 6 | def say_hi_from_houdini(): 7 | """ 8 | This says Hi in Houdini 9 | 10 | :return: *string* 11 | """ 12 | print("Hi from Houdini!") 13 | return("I said hi") 14 | 15 | def create_node(path, type, name="NisseNode"): 16 | obj = hou.node(path) 17 | obj.createNode(type, name) 18 | -------------------------------------------------------------------------------- /skyhook/modules/maya_mod.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | try: 3 | import pymel.core as pm 4 | import maya.cmds as cmds 5 | import maya.utils as utils 6 | except: 7 | print(str(traceback.format_exc())) 8 | pass 9 | 10 | 11 | def make_cube(): 12 | """ 13 | Example function that will make a cube named Nisse 14 | 15 | :return: 16 | """ 17 | pm.polyCube(name="Nisse") 18 | return None 19 | 20 | def make_sphere(name="Nisse", create_uvs=1, radius=3): 21 | """ 22 | Example function that will make a sphere name Nisse 23 | 24 | :return: 25 | """ 26 | pm.polySphere(name=name, createUVs=create_uvs, radius=radius) 27 | return "I made a sphere" 28 | 29 | def raw_maya(command, args=[], kwargs={}): 30 | """ 31 | Handle a "raw" maya.cmds or pymel command. Expects args to be a list and kwargs to be a dictionary. 32 | 33 | Eg:: 34 | import skyhook.client 35 | maya_client = skyhook.client.MayaClient() 36 | maya_client.execute("raw_maya", {"command": "pm.polySphere", "kwargs": {"radius": 20}}) 37 | 38 | 39 | :param command: *string* of complete command. eg: pm.ls or cmds.polyCube 40 | :param args: *list* of unnamed arguments 41 | :param kwargs: *dict* with keywords that are needed for your command 42 | :return: *string* of whatever the command returned in Maya 43 | """ 44 | kwargs = dict((str(k), v) for k, v in kwargs.items()) 45 | func = eval(command) 46 | print(kwargs) 47 | 48 | 49 | result = func(*args, **kwargs) 50 | return str(result) 51 | 52 | 53 | def execute_python(python_script): 54 | """ 55 | Executes a complete Python script 56 | 57 | :param python_script: *string* Python code 58 | :return: None 59 | """ 60 | exec(python_script) 61 | 62 | 63 | def warning(message): 64 | pm.warning(message) 65 | 66 | 67 | def new_scene(): 68 | pm.newFile(force=True) 69 | -------------------------------------------------------------------------------- /skyhook/modules/substance_painter.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/modules/substance_painter.py -------------------------------------------------------------------------------- /skyhook/modules/unreal.py: -------------------------------------------------------------------------------- 1 | ########################################################### 2 | # 3 | # It's impossible to have the skyhook functionality in this file at this time, due to the way 4 | # Unreal loads its Python scripts. So until there's a better way of doing this, please edit the 5 | # SkyhookCommands class in the skyhook module in the project's Python folder 6 | # 7 | ########################################################### 8 | 9 | pass 10 | -------------------------------------------------------------------------------- /skyhook/resources/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | logo_transparent = os.path.join(os.path.dirname(__file__), "sky_hook_logo_transparent.png") 3 | label_transparent = os.path.join(os.path.dirname(__file__), "sky_hook_label_transparent.png") -------------------------------------------------------------------------------- /skyhook/resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/resources/favicon.ico -------------------------------------------------------------------------------- /skyhook/resources/sky_hook_label_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/resources/sky_hook_label_transparent.png -------------------------------------------------------------------------------- /skyhook/resources/sky_hook_logo_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/resources/sky_hook_logo_transparent.png -------------------------------------------------------------------------------- /skyhook/server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import threading 3 | import time 4 | import socket 5 | import importlib 6 | import inspect 7 | import types 8 | import json 9 | import traceback 10 | import urllib.parse 11 | 12 | from datetime import datetime 13 | from importlib import reload 14 | from http.server import BaseHTTPRequestHandler, HTTPServer 15 | from typing import Any, Dict, List, Optional, TypeVar, Union, Callable, Type 16 | 17 | from .constants import Constants, Results, ServerCommands, Errors, Ports, HostPrograms, ServerEvents 18 | from .modules import core 19 | from .logger import Logger 20 | logger = Logger() 21 | 22 | T = TypeVar('T') 23 | 24 | class EventEmitter: 25 | """ 26 | A simple event emitter similar to Qt's signal/slot mechanism. 27 | """ 28 | def __init__(self) -> None: 29 | self._callbacks: Dict[str, List[Callable[..., Any]]] = {} 30 | 31 | def connect(self, event_name: str, callback: Callable[..., Any]) -> None: 32 | """ 33 | Connect a callback to an event 34 | """ 35 | if event_name not in self._callbacks: 36 | self._callbacks[event_name] = [] 37 | self._callbacks[event_name].append(callback) 38 | 39 | def emit(self, event_name: str, *args: Any, **kwargs: Any) -> None: 40 | """ 41 | Emit an event with arguments 42 | """ 43 | if event_name in self._callbacks: 44 | for callback in self._callbacks[event_name]: 45 | callback(*args, **kwargs) 46 | 47 | 48 | class GenericMainThreadExecutor: 49 | """ 50 | You can spawn an object of this class in the main thread of a program, while the server runs in a different thread. Any code 51 | execution will happen in the main thread and the server can run on a separate, non-blocking thread. Useful when you're 52 | running a Skyhook server inside another program. 53 | """ 54 | def __init__(self, server: 'Server') -> None: 55 | self.server: 'Server' = server 56 | logger.info(f"Executor started: {type(self)}") 57 | 58 | def execute(self, function_name: str, parameters_dict: Dict[str, Any]) -> None: 59 | """ 60 | Executes a function. The response from the SkyHook server will not be returned here, but set in the server's 61 | executor_reply property. When using a MainThreadExecutor, the server will block until its executor_reply 62 | property is set to anything but None. 63 | 64 | :param function_name: *string* name of the function, it will be searched for in the server 65 | :param parameters_dict: *dict* with parameters 66 | :return: None 67 | """ 68 | parameters_dict.pop(Constants.module, None) 69 | function: Callable[..., Any] = self.server.get_function_by_name(function_name) 70 | try: 71 | return_value: Any = function(**parameters_dict) 72 | success: bool = True 73 | logger.success(f"MainThreadExecutor executed {function}") 74 | except Exception as err: 75 | trace: str = str(traceback.format_exc()) 76 | return_value = trace 77 | success = False 78 | logger.error(f"MainThreadExecutor couldn't execute {function}") 79 | logger.error(str(err)) 80 | logger.error(trace) 81 | 82 | result_json: Dict[str, Any] = make_result_json(success, return_value, function_name) 83 | self.server.executor_reply = result_json 84 | 85 | 86 | class MayaExecutor(GenericMainThreadExecutor): 87 | """ 88 | A specific MainThreadExecutor for Maya. It will run any code using maya.utils.executeInMainThread. 89 | """ 90 | def __init__(self, server: 'Server') -> None: 91 | super().__init__(server) 92 | 93 | try: 94 | import maya.utils 95 | self.executeInMainThreadWithResult: Callable[..., Any] = maya.utils.executeInMainThreadWithResult 96 | logger.success("Fetched maya.utils.executeInMainThreadWithResult") 97 | except: 98 | logger.error("Couldn't fetch maya.utils.executeInMainThreadWithResult") 99 | 100 | def execute(self, function_name: str, parameters_dict: Dict[str, Any]) -> None: 101 | parameters_dict.pop(Constants.module, None) 102 | function: Callable[..., Any] = self.server.get_function_by_name(function_name) 103 | 104 | try: 105 | return_value: Any = self.executeInMainThreadWithResult(function, **parameters_dict) 106 | success: bool = True 107 | logger.success(f"MayaExecutor executed {function}") 108 | except Exception as err: 109 | trace: str = str(traceback.format_exc()) 110 | return_value = trace 111 | success = False 112 | logger.error(f"MayaExecutor couldn't execute {function}") 113 | logger.error(str(err)) 114 | logger.error(trace) 115 | 116 | result_json: Dict[str, Any] = make_result_json(success, return_value, function_name) 117 | self.server.executor_reply = result_json 118 | 119 | 120 | class BlenderExecutor(GenericMainThreadExecutor): 121 | """ 122 | A specific MainThreadExecutor for Blender. It will schedule any code to be registered with 123 | bpy.app.timers, where it will run in the main UI thread of Blender. It uses an inner function 124 | that will wrap the actual code in order to get a proper result back and make sure we can wait 125 | until the code execution is done before we continue. 126 | """ 127 | def __init__(self, server: 'Server') -> None: 128 | super().__init__(server) 129 | 130 | try: 131 | import bpy.app.timers 132 | self.register: Callable[[Callable[[], Optional[float]]], None] = bpy.app.timers.register 133 | logger.success("Fetched bpy.app.timers.register") 134 | except: 135 | logger.error("Couldn't fetch bpy.app.timers.register") 136 | 137 | def execute(self, function_name: str, parameters_dict: Dict[str, Any]) -> None: 138 | parameters_dict.pop(Constants.module, None) 139 | function: Callable[..., Any] = self.server.get_function_by_name(function_name) 140 | 141 | done_event: threading.Event = threading.Event() 142 | result: List[Optional[Any]] = [None] 143 | error: List[Optional[str]] = [None] 144 | 145 | def timer_function() -> None: 146 | try: 147 | logger.info(function) 148 | result[0] = function(**parameters_dict) 149 | except Exception as e: 150 | error[0] = str(e) 151 | finally: 152 | logger.info("we're done!") 153 | done_event.set() 154 | 155 | # Unregister timer 156 | return None 157 | 158 | # run the function in Blenders's main thread 159 | self.register(timer_function) 160 | done_event.wait() # Wait until done_event is set(), then continue 161 | 162 | if error[0]: 163 | success: bool = False 164 | logger.error(f"BlenderExecutor couldn't execute {function}") 165 | result_json: Dict[str, Any] = make_result_json(success, error[0], function_name) 166 | else: 167 | success = True 168 | logger.success(f"BlenderExecutor executed {function}") 169 | result_json = make_result_json(success, result[0], function_name) 170 | self.server.executor_reply = result_json 171 | 172 | 173 | class Server: 174 | """ 175 | Main SkyHook server class 176 | 177 | There are 3 callbacks that you can hook into from the outside: 178 | ServerEvents.is_terminated: called when stop_listening() is called 179 | ServerEvents.exec_command: called when a non-server command is executed through the MainThreadExecutor 180 | ServerEvents.command: always called with the command name and parameter dictionary, 181 | whether the command was a non-server or server command 182 | """ 183 | def __init__( 184 | self, 185 | host_program: Optional[str] = None, 186 | port: Optional[int] = None, 187 | load_modules: List[str] = [], 188 | use_main_thread_executor: bool = False, 189 | echo_response: bool = True 190 | ) -> None: 191 | 192 | self.events: EventEmitter = EventEmitter() 193 | self.executor_reply: Optional[Dict[str, Any]] = None 194 | 195 | if port: 196 | self.port: int = port 197 | else: 198 | self.port = self.__get_host_program_port(host_program) 199 | 200 | self.__host_program: Optional[str] = host_program 201 | self.__keep_running: bool = True 202 | self.__use_main_thread_executor: bool = use_main_thread_executor 203 | self.__http_server: Optional[HTTPServer] = None 204 | self.__echo_response: bool = echo_response 205 | self.__loaded_modules: List[types.ModuleType] = [] 206 | self.__loaded_modules.append(core) 207 | 208 | for module_name in load_modules: 209 | self.hotload_module(module_name) 210 | 211 | def start_listening(self) -> None: 212 | """ 213 | Creates a RequestHandler and starts the listening for incoming requests. Will continue to listen for requests 214 | until self.__keep_running is set to False 215 | 216 | :return: None 217 | """ 218 | def handler(*args: Any) -> None: 219 | SkyHookHTTPRequestHandler(skyhook_server=self, *args) 220 | 221 | self.__http_server = HTTPServer(("127.0.0.1", self.port), handler) 222 | logger.info(f"Started SkyHook on port: {self.port}") 223 | while self.__keep_running: 224 | self.__http_server.handle_request() 225 | logger.info("Shutting down server") 226 | self.__http_server.server_close() 227 | logger.info("Server shut down") 228 | 229 | def stop_listening(self) -> None: 230 | """ 231 | Stops listening for incoming requests. Also emits the is_terminated event with "TERMINATED" 232 | 233 | :return: None 234 | """ 235 | self.__keep_running = False 236 | if self.__http_server: 237 | self.__http_server.server_close() 238 | self.events.emit("is_terminated", "TERMINATED") 239 | 240 | def reload_modules(self) -> List[str]: 241 | """ 242 | Reloads all the currently loaded modules. This can also be called remotely by sending 243 | ServerCommands.SKY_RELOAD_MODULES as the FunctionName. 244 | 245 | :return: List of reloaded module names 246 | """ 247 | reload_module_names: List[str] = [] 248 | 249 | for module in self.__loaded_modules: 250 | try: 251 | reload(module) 252 | reload_module_names.append(module.__name__) 253 | except: 254 | importlib.reload(module) 255 | return reload_module_names 256 | 257 | def hotload_module(self, module_name: Union[str, types.ModuleType], is_skyhook_module: bool = True) -> None: 258 | """ 259 | Tries to load a module with module_name from skyhook.modules to be added to the server while it's running. 260 | 261 | :param module_name: *string* name of the module you want to load 262 | :param is_skyhook_module: *bool* name of the module you want to load 263 | :return: None 264 | """ 265 | if isinstance(module_name, types.ModuleType): 266 | if is_skyhook_module: 267 | module_name = module_name.__name__.split(".")[-1] 268 | else: 269 | module_name = module_name.__name__ 270 | 271 | try: 272 | if is_skyhook_module: 273 | mod: types.ModuleType = importlib.import_module(f".modules.{module_name}", package="skyhook") 274 | else: 275 | mod = importlib.import_module(module_name) 276 | 277 | if not mod in self.__loaded_modules: 278 | self.__loaded_modules.append(mod) 279 | logger.info(f"Added {mod}") 280 | logger.info(self.__loaded_modules) 281 | except Exception as err: 282 | logger.error(f"Failed to hotload: {module_name}") 283 | logger.error(err) 284 | 285 | def unload_modules(self, module_name: str, is_skyhook_module: bool = True) -> None: 286 | """ 287 | Removes a module from the server 288 | 289 | :param module_name: *string* name of the module you want to remove 290 | :param is_skyhook_module: *bool* set this to true if the module you want to remove is a skyhook module 291 | :return: None 292 | """ 293 | if is_skyhook_module: 294 | full_name: str = "skyhook.modules." + module_name 295 | else: 296 | full_name = module_name 297 | for module in self.__loaded_modules: 298 | if module.__name__ == full_name: 299 | self.__loaded_modules.remove(module) 300 | 301 | def get_function_by_name(self, function_name: str, module_name: Optional[str] = None) -> Callable[..., Any]: 302 | """ 303 | Tries to find a function by name. 304 | 305 | :param function_name: *string* name of the function you're looking for 306 | :param module_name: *string* name of the module to search in. If None, all modules are searched 307 | :return: the actual function that matches your name, or None 308 | """ 309 | if module_name is not None: 310 | module = sys.modules.get(f".modules.{module_name}") 311 | if module is not None: 312 | modules = [module] 313 | else: 314 | module = sys.modules.get(module_name) 315 | modules = [module] 316 | else: 317 | modules = self.__loaded_modules 318 | 319 | for module in modules: 320 | for name, value in module.__dict__.items(): 321 | if callable(value): 322 | if function_name == name: 323 | return value 324 | 325 | # If no function is found, return a dummy function that raises an error 326 | def function_not_found(*args: Any, **kwargs: Any) -> None: 327 | raise ValueError(f"Function {function_name} not found") 328 | 329 | return function_not_found 330 | 331 | def filter_and_execute_function(self, function_name: str, parameters_dict: Dict[str, Any]) -> bytes: 332 | """ 333 | This function decides whether or the function call should come from one of the loaded modules or from the server. 334 | Every server function should start with SKY_ 335 | 336 | You shouldn't call this function directly. 337 | 338 | :param function_name: Name of the function to execute 339 | :param parameters_dict: Dictionary of parameters to pass to the function 340 | :return: JSON response as bytes 341 | """ 342 | if function_name in dir(ServerCommands): 343 | result_json: Dict[str, Any] = self.__process_server_command(function_name, parameters_dict) 344 | else: 345 | result_json = self.__process_module_command(function_name, parameters_dict) 346 | 347 | self.executor_reply = None 348 | return json.dumps(result_json).encode() 349 | 350 | def __process_server_command(self, function_name: str, parameters_dict: Dict[str, Any] = {}) -> Dict[str, Any]: 351 | """ 352 | Processes a command if the function in it was a server function. 353 | 354 | You shouldn't call this function directly 355 | 356 | :param function_name: Name of the server command to execute 357 | :param parameters_dict: Dictionary of parameters for the command 358 | :return: Result JSON dictionary 359 | """ 360 | result_json: Dict[str, Any] = make_result_json(False, Errors.SERVER_COMMAND, None) 361 | 362 | if function_name == ServerCommands.SKY_SHUTDOWN: 363 | self.stop_listening() 364 | result_json = make_result_json(True, "Server offline", ServerCommands.SKY_SHUTDOWN) 365 | 366 | elif function_name == ServerCommands.SKY_LS: 367 | functions: List[str] = [] 368 | for module in self.__loaded_modules: 369 | functions.extend([func for func in dir(module) if not "__" in func or isinstance(func, types.FunctionType)]) 370 | result_json = make_result_json(True, functions, ServerCommands.SKY_LS) 371 | 372 | elif function_name == ServerCommands.SKY_RELOAD_MODULES: 373 | reloaded_modules: List[str] = self.reload_modules() 374 | result_json = make_result_json(True, reloaded_modules, ServerCommands.SKY_RELOAD_MODULES) 375 | 376 | elif function_name == ServerCommands.SKY_UNLOAD: 377 | modules: List[str] = parameters_dict.get(Constants.module, []) 378 | for module in modules: 379 | self.unload_modules(module) 380 | 381 | result_json = make_result_json(True, [m.__name__ for m in self.__loaded_modules], ServerCommands.SKY_UNLOAD) 382 | 383 | elif function_name == ServerCommands.SKY_FUNCTION_HELP: 384 | function_name_param: str = parameters_dict.get("function_name", "") 385 | function: Callable[..., Any] = self.get_function_by_name(function_name_param) 386 | arg_spec = inspect.getfullargspec(function) 387 | 388 | arg_spec_dict: Dict[str, Any] = { 389 | "function_name": function_name_param, 390 | "arguments": arg_spec.args 391 | } 392 | 393 | if arg_spec.varargs is not None: 394 | arg_spec_dict["packed_args"] = f"*{arg_spec.varargs}" 395 | if arg_spec.keywords is not None: 396 | arg_spec_dict["packed_kwargs"] = f"**{arg_spec.keywords}" 397 | 398 | result_json = make_result_json(True, arg_spec_dict, ServerCommands.SKY_FUNCTION_HELP) 399 | 400 | self.events.emit(ServerEvents.command, function_name, {}) 401 | logger.success(f"Executed {function_name}") 402 | 403 | return result_json 404 | 405 | def __process_module_command(self, function_name: str, parameters_dict: Dict[str, Any], timeout: float = 10.0) -> Dict[str, Any]: 406 | """ 407 | Processes a command if the function in it was a module function. 408 | 409 | You shouldn't call this function directly 410 | 411 | :param function_name: Name of the function to execute 412 | :param parameters_dict: Dictionary of parameters for the function 413 | :param timeout: Timeout in seconds for waiting for executor reply 414 | :return: Result JSON dictionary 415 | """ 416 | if self.__use_main_thread_executor: 417 | logger.debug("Emitting exec_command event") 418 | self.events.emit(ServerEvents.exec_command, function_name, parameters_dict) 419 | start_time: float = time.time() 420 | 421 | while self.executor_reply is None: 422 | # wait for the executor to reply, just keep checking the time to see if we've waited longer 423 | # than timeout permits 424 | current_time: float = time.time() 425 | if current_time - start_time > timeout: 426 | self.executor_reply = make_result_json(success=False, return_value=Errors.TIMEOUT, command=function_name) 427 | return self.executor_reply 428 | 429 | module: Optional[str] = parameters_dict.get(Constants.module) 430 | parameters_dict.pop(Constants.module, None) # remove module from the parameters, otherwise it gets passed to the function 431 | function: Callable[..., Any] = self.get_function_by_name(function_name, module) 432 | 433 | try: 434 | return_value: Any = function(**parameters_dict) 435 | success: bool = True 436 | self.events.emit(ServerEvents.command, function_name, parameters_dict) 437 | except Exception as err: 438 | trace: str = str(traceback.format_exc()) 439 | return_value = trace 440 | success = False 441 | 442 | result_json: Dict[str, Any] = make_result_json(success, return_value, function_name) 443 | return result_json 444 | 445 | def __get_host_program_port(self, host_program: Optional[str]) -> int: 446 | """ 447 | Looks through the Constants.Ports class to try and find the port for the host program. If it can't, it will 448 | default to Constants.Ports.undefined 449 | 450 | You shouldn't call this function directly 451 | 452 | :param host_program: *string* name of the host program 453 | :return: Port number as integer 454 | """ 455 | try: 456 | if host_program is not None: 457 | return getattr(Ports, host_program) 458 | return getattr(Ports, Constants.undefined) 459 | except Exception: 460 | return getattr(Ports, Constants.undefined) 461 | 462 | 463 | class SkyHookHTTPRequestHandler(BaseHTTPRequestHandler): 464 | """ 465 | The class that handles requests coming into the server. It holds a reference to the Server class, so 466 | it can call the Server functions after it has processed the request. 467 | """ 468 | skyhook_server: Server 469 | 470 | def __init__( 471 | self, 472 | request: socket.socket, 473 | client_address: tuple, 474 | server: HTTPServer, 475 | skyhook_server: Optional[Server] = None, 476 | reply_with_auto_close: bool = True 477 | ) -> None: 478 | 479 | self.skyhook_server = skyhook_server 480 | self.reply_with_auto_close = reply_with_auto_close 481 | self.quiet = False 482 | super().__init__(request, client_address, server) 483 | 484 | def do_GET(self) -> None: 485 | """ 486 | Handle a GET request, this would be just a browser requesting a particular url 487 | like: http://localhost:65500/%22echo_message%22&%7B%22message%22:%22Hooking%20into%20the%20Sky%22%7D 488 | """ 489 | # browsers tend to send a GET for the favicon as well, which we don't care about 490 | if self.path == "/favicon.ico": 491 | return 492 | 493 | data: str = urllib.parse.unquote(self.path).lstrip("/") 494 | parts: List[str] = data.split("&") 495 | 496 | try: 497 | function: str = eval(parts[0]) 498 | parameters: Dict[str, Any] = json.loads(parts[1]) 499 | except NameError as err: 500 | logger.warning(f"Got a GET request that I don't know what to do with") 501 | logger.warning(f"Request was: GET {data}") 502 | logger.warning(f"Error is : {err}") 503 | return 504 | 505 | command_response: bytes = self.skyhook_server.filter_and_execute_function(function, parameters) 506 | self.send_response_data("GET") 507 | self.wfile.write(bytes(f"{command_response}".encode("utf-8"))) 508 | if self.reply_with_auto_close: 509 | # reply back to the browser with a javascript that will close the window (tab) it just opened 510 | self.wfile.write(bytes("".encode("utf-8"))) 511 | 512 | def do_POST(self) -> None: 513 | """ 514 | Handle a post request, expects a JSON object to be sent in the POST data 515 | """ 516 | content_length: int = int(self.headers['Content-Length']) 517 | post_data: bytes = self.rfile.read(content_length) 518 | decoded_data: str = post_data.decode('utf-8') 519 | json_data: Dict[str, Any] = json.loads(decoded_data) 520 | 521 | function: str = json_data.get(Constants.function_name, "") 522 | parameters: Dict[str, Any] = json_data.get(Constants.parameters, {}) 523 | 524 | command_response: bytes = self.skyhook_server.filter_and_execute_function(function, parameters) 525 | self.send_response_data("POST") 526 | self.wfile.write(command_response) 527 | 528 | def send_response_data(self, request_type: str) -> None: 529 | """ 530 | Generates the response and appropriate headers to send back to the client 531 | 532 | :param request_type: *string* GET or POST 533 | """ 534 | self.send_response(200) 535 | if request_type == "GET": 536 | self.send_header('Content-type', 'text/html') 537 | elif request_type == "POST": 538 | self.send_header('Content-type', 'application/json') 539 | else: 540 | pass 541 | self.end_headers() 542 | 543 | def log_message(self, fmt, *args): 544 | if not self.quiet: 545 | super().log_message(fmt, *args) 546 | 547 | 548 | def port_in_use(port_number: int, host: str = "127.0.0.1", timeout: float = 0.125) -> bool: 549 | """ 550 | Checks if a specific ports is already in use by a different process. Useful if you want to check if you can start 551 | a SkyHook server on a default port that might already be in use. 552 | 553 | :param port_number: Port you want to check 554 | :param host: Hostname, default is 127.0.0.1 555 | :param timeout: when the socket should time out 556 | :return: True if port is in use, False otherwise 557 | """ 558 | sock: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 559 | sock.settimeout(timeout) 560 | result: int = sock.connect_ex((host, port_number)) 561 | if result == 0: 562 | return True 563 | return False 564 | 565 | 566 | def make_result_json(success: bool, return_value: Any, command: str) -> Dict[str, Any]: 567 | """ 568 | Little helper function to construct a json for the server to send back after a POST request 569 | 570 | :param success: Whether the command was successful 571 | :param return_value: The return value of the command 572 | :param command: The command that was executed 573 | :return: Dictionary with result information 574 | """ 575 | result_json: Dict[str, Any] = { 576 | Results.time: datetime.now().strftime("%H:%M:%S"), 577 | Results.success: success, 578 | Results.return_value: return_value, 579 | Results.command: command 580 | } 581 | 582 | return result_json 583 | 584 | 585 | def start_server_in_thread( 586 | host_program: str = "", 587 | port: Optional[int] = None, 588 | load_modules: List[str] = [], 589 | echo_response: bool = False 590 | ) -> List[Optional[Union[threading.Thread, Server]]]: 591 | """ 592 | Starts a server in a separate thread. 593 | 594 | :param host_program: *string* name of the host program 595 | :param port: *int* port override 596 | :param load_modules: *list* modules to load 597 | :param echo_response: *bool* print the response the server is sending back 598 | :return: *List* containing Thread and Server or None values if server couldn't start 599 | """ 600 | if host_program != "": 601 | port = getattr(Ports, host_program) 602 | 603 | if port_in_use(port): 604 | logger.error(f"Port {port} is already in use, can't start server") 605 | return [None, None] 606 | 607 | skyhook_server: Server = Server(host_program=host_program, port=port, load_modules=load_modules, echo_response=echo_response) 608 | 609 | def kill_thread_callback(message: str) -> None: 610 | logger.info("\nShyhook server thread was killed") 611 | 612 | skyhook_server.events.connect(ServerEvents.is_terminated, kill_thread_callback) 613 | 614 | thread: threading.Thread = threading.Thread(target=skyhook_server.start_listening) 615 | thread.daemon = True 616 | thread.start() 617 | 618 | return [thread, skyhook_server] 619 | 620 | 621 | def start_executor_server_in_thread( 622 | host_program: str = "", 623 | port: Optional[int] = None, 624 | load_modules: List[str] = [], 625 | echo_response: bool = False, 626 | executor: Optional[GenericMainThreadExecutor] = None, 627 | executor_name: Optional[str] = None 628 | ) -> List[Optional[Union[threading.Thread, GenericMainThreadExecutor, Server]]]: 629 | """ 630 | Starts a server in a thread, but moves all executing functionality to a MainThreadExecutor object. Use this 631 | for a host program where executing code in a thread other than the main thread is not safe. Eg: Blender 632 | 633 | :param host_program: *string* name of the host program 634 | :param port: *int* port override 635 | :param load_modules: *list* modules to load 636 | :param echo_response: *bool* print the response the server is sending back 637 | :param executor: Optional executor instance to use 638 | :param executor_name: Name of an executor to use, for example "maya" or "blender" 639 | :return: *List* containing Thread, MainThreadExecutor and Server or None values if server couldn't start 640 | """ 641 | if host_program != "": 642 | port = getattr(Ports, host_program) 643 | 644 | if port is None: 645 | port = getattr(Ports, Constants.undefined) 646 | 647 | if port_in_use(port): 648 | logger.error(f"Port {port} is already in use, can't start server") 649 | return [None, None, None] 650 | 651 | skyhook_server: Server = Server(host_program=host_program, port=port, load_modules=load_modules, 652 | use_main_thread_executor=True, echo_response=echo_response) 653 | 654 | executor_mapping: Dict[str, Type[GenericMainThreadExecutor]] = { 655 | HostPrograms.maya: MayaExecutor, 656 | HostPrograms.blender: BlenderExecutor, 657 | } 658 | 659 | # if we didn't specify an executor to use, figure out which one we should make 660 | if executor is None: 661 | executor_key: Optional[str] = None 662 | 663 | # executor_name > host_program > default 664 | if executor_name is not None: 665 | executor_key = executor_name 666 | logger.info(f"Using executor specified by name: {executor_name}") 667 | elif host_program: 668 | executor_key = host_program 669 | logger.info(f"Using executor based on host program: {host_program}") 670 | 671 | # we found which one to use in the mapping 672 | if executor_key in executor_mapping: 673 | executor_class = executor_mapping[executor_key] 674 | # make the executor 675 | executor = executor_class(skyhook_server) 676 | logger.info(f"Created {executor_class.__name__}") 677 | # we couldn't find one, make a GenericMainThreadExecutor 678 | else: 679 | logger.warning("No specific executor found, using GenericMainThreadExecutor") 680 | executor = GenericMainThreadExecutor(skyhook_server) 681 | 682 | # Connect the exec_command event to the executor's execute method 683 | skyhook_server.events.connect(ServerEvents.exec_command, executor.execute) 684 | 685 | def kill_thread_callback(message: str) -> None: 686 | logger.info("\nSkyhook server thread was killed") 687 | 688 | skyhook_server.events.connect("is_terminated", kill_thread_callback) 689 | 690 | # running the server on a separate thread 691 | thread: threading.Thread = threading.Thread(target=skyhook_server.start_listening) 692 | thread.daemon = True 693 | thread.start() 694 | 695 | return [thread, executor, skyhook_server] 696 | 697 | 698 | def start_blocking_server( 699 | host_program: str = "", 700 | port: Optional[int] = None, 701 | load_modules: List[str] = [], 702 | echo_response: bool = False 703 | ) -> Optional[Server]: 704 | """ 705 | Starts a server in the main thread. This will block your application. Use this when you don't care about your 706 | application being locked up. 707 | 708 | :param host_program: *string* name of the host program 709 | :param port: *int* port override 710 | :param load_modules: *list* modules to load 711 | :param echo_response: *bool* print the response the server is sending back 712 | :return: Server instance or None if server couldn't start 713 | """ 714 | if host_program != "": 715 | port = getattr(Ports, host_program) 716 | 717 | if port is None: 718 | port = getattr(Ports, Constants.undefined) 719 | 720 | if port_in_use(port): 721 | logger.error(f"Port {port} is already in use, can't start server") 722 | return None 723 | 724 | skyhook_server: Server = Server(host_program=host_program, port=port, load_modules=load_modules, echo_response=echo_response) 725 | skyhook_server.start_listening() 726 | return skyhook_server -------------------------------------------------------------------------------- /wiki-images/UE_Logo_Icon_Black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/UE_Logo_Icon_Black.png -------------------------------------------------------------------------------- /wiki-images/blender_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/blender_logo.png -------------------------------------------------------------------------------- /wiki-images/dragon_martin_woortman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/dragon_martin_woortman.png -------------------------------------------------------------------------------- /wiki-images/houdinibadge.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/houdinibadge.jpg -------------------------------------------------------------------------------- /wiki-images/houdinibadge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/houdinibadge.png -------------------------------------------------------------------------------- /wiki-images/maya_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/maya_logo.png -------------------------------------------------------------------------------- /wiki-images/substance_painter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/substance_painter.png --------------------------------------------------------------------------------